You shipped a dark mode toggle behind a feature flag eight months ago. Users loved it. Product celebrated. The flag went to 100% on day three. Today, that flag still controls the rendering path for every single page in your application, buried inside a React context provider that wraps your entire component tree. Three new engineers have joined since then, and each one has carefully avoided touching the flag because it "seems important."
This is the quiet crisis in TypeScript and React codebases. Feature flags don't just linger in one file -- they spread through component trees, infect prop interfaces, bloat context providers, and create type definitions that reference conditions that will never be false again. And because TypeScript's type system is so expressive, stale flags often survive indefinitely, protected by types that make dead code look alive.
In our experience, React applications with active feature flagging accumulate flag references throughout the codebase at a surprising rate. In applications older than two years, a large proportion of those references point to flags that have been fully rolled out or abandoned. That is dead weight woven into every render cycle, every code review, and every onboarding session.
How feature flags accumulate in React codebases
React's component model creates unique challenges for flag management. Unlike backend services where a flag might appear in a single handler function, React flags tend to propagate outward from a single decision point into props, contexts, hooks, and conditional renders across dozens of files.
The propagation pattern
A flag that starts as a simple boolean check in one component quickly spreads:
// Stage 1: Simple conditional render (Day 1)
function Dashboard() {
const showNewMetrics = useFeatureFlag('new-metrics-panel');
return showNewMetrics ? <NewMetrics /> : <LegacyMetrics />;
}
// Stage 2: Flag leaks into props (Week 2)
function Dashboard() {
const showNewMetrics = useFeatureFlag('new-metrics-panel');
return (
<DashboardLayout showNewMetrics={showNewMetrics}>
{showNewMetrics ? <NewMetrics /> : <LegacyMetrics />}
<Sidebar variant={showNewMetrics ? 'wide' : 'narrow'} />
<ExportButton format={showNewMetrics ? 'csv' : 'pdf'} />
</DashboardLayout>
);
}
// Stage 3: Flag infects type definitions (Month 1)
interface DashboardLayoutProps {
showNewMetrics: boolean;
children: React.ReactNode;
}
interface SidebarProps {
variant: 'wide' | 'narrow';
}
interface ExportButtonProps {
format: 'csv' | 'pdf';
}
By Stage 3, the flag has touched four components, three interfaces, and two files. Removing it requires understanding the full propagation chain. Multiply this by 20 or 30 active flags, and cleanup becomes a significant engineering effort.
Common flag patterns in React
Understanding the patterns flags take in React applications is the first step toward systematic cleanup. Here are the most common shapes:
1. Hook-based flags
function useFeatureFlag(key: string): boolean {
const { flags } = useContext(FeatureFlagContext);
return flags[key] ?? false;
}
// Usage spreads quickly
function PricingPage() {
const showAnnualToggle = useFeatureFlag('annual-pricing');
const showEnterpriseTier = useFeatureFlag('enterprise-tier');
const showDiscountBanner = useFeatureFlag('summer-discount');
// ...
}
2. Higher-Order Component wrappers
function withFeatureFlag<P extends object>(
Component: React.ComponentType<P>,
flagKey: string,
Fallback?: React.ComponentType<P>
) {
return function WrappedComponent(props: P) {
const isEnabled = useFeatureFlag(flagKey);
if (isEnabled) return <Component {...props} />;
return Fallback ? <Fallback {...props} /> : null;
};
}
// Creates invisible coupling
const EnhancedCheckout = withFeatureFlag(
NewCheckoutFlow,
'checkout-v2',
LegacyCheckoutFlow
);
3. Context provider flags
interface FeatureConfig {
enableNewNav: boolean;
enableDarkMode: boolean;
enableRealTimeUpdates: boolean;
enableBetaAnalytics: boolean;
}
const FeatureConfigContext = createContext<FeatureConfig>({
enableNewNav: false,
enableDarkMode: false,
enableRealTimeUpdates: false,
enableBetaAnalytics: false,
});
4. Conditional rendering with ternaries
return (
<div>
{showNewHeader ? <HeaderV2 /> : <Header />}
<main>
{enableSidebar && <Sidebar />}
<Content layout={useCompactLayout ? 'compact' : 'standard'} />
</main>
{showNewFooter ? <FooterV2 onSubscribe={handleSubscribe} /> : <Footer />}
</div>
);
Each pattern creates a different cleanup challenge. Hook-based flags require tracing every consumer. HOC wrappers hide the flag from the call site entirely. Context providers keep flag references alive even when no component reads them. Conditional ternaries embed flags directly in JSX, making them easy to spot but tedious to remove across large component trees.
TypeScript-specific detection challenges
TypeScript's type system introduces detection challenges that don't exist in plain JavaScript. Flags become encoded in types, and TypeScript's structural typing means dead code can survive type checking indefinitely.
Type narrowing with flags
TypeScript's control flow analysis interacts with feature flags in subtle ways:
interface UserProfile {
name: string;
email: string;
// Added for the 'advanced-profile' flag
bio?: string;
socialLinks?: SocialLinks;
preferences?: UserPreferences;
}
function ProfileEditor({ user, flags }: ProfileEditorProps) {
if (flags.advancedProfile) {
// TypeScript narrows nothing here -- flags.advancedProfile
// is just a boolean, not a type guard
return (
<AdvancedEditor
user={user}
bio={user.bio ?? ''}
socialLinks={user.socialLinks ?? DEFAULT_SOCIAL}
preferences={user.preferences ?? DEFAULT_PREFS}
/>
);
}
return <BasicEditor user={user} />;
}
When the advanced-profile flag is fully rolled out, the BasicEditor component becomes dead code. But TypeScript does not know this. The optional fields on UserProfile remain optional, the BasicEditor import stays valid, and no compiler warning fires. The dead branch passes every type check perfectly.
Dead types from flag branches
Flags often introduce types that only exist to serve one branch of a conditional:
// These types only exist for the legacy path
interface LegacyCartItem {
id: string;
quantity: number;
price: number;
}
interface LegacyCartState {
items: LegacyCartItem[];
total: number;
}
// New types for the flagged path
interface CartItem {
id: string;
quantity: number;
unitPrice: Money;
lineTotal: Money;
discounts: Discount[];
}
interface CartState {
items: CartItem[];
subtotal: Money;
tax: Money;
total: Money;
appliedPromotions: Promotion[];
}
When the new cart is permanently enabled, LegacyCartItem and LegacyCartState become dead types. TypeScript will never flag them because they are still valid type definitions. They will sit in your codebase, appearing in autocomplete suggestions and confusing developers who wonder which cart types to use.
Quantifying the TypeScript flag debt
| Artifact Type | Average Count Per Stale Flag | Detection Difficulty |
|---|---|---|
| Direct boolean checks | 3-5 | Low |
| Prop interface fields | 2-4 | Medium |
| Context provider references | 1-2 | Medium |
| Dead component imports | 1-3 | Low |
| Dead type definitions | 2-6 | High |
| Dead test scenarios | 3-8 | High |
| Stale Storybook stories | 1-3 | Medium |
A single stale flag in a typical React/TypeScript codebase leaves behind 13-31 artifacts that need cleanup. At 20 stale flags, that is 260-620 code artifacts silently degrading your codebase.
Detection techniques for TypeScript and React
Static analysis with ESLint
Custom ESLint rules are one of the most effective first lines of defense against flag accumulation. They integrate directly into the development workflow and can catch problems before code is merged.
Rule 1: Flag age enforcement
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'CallExpression[callee.name="useFeatureFlag"]',
message: 'Feature flag hooks must include a cleanup date comment. ' +
'Add: // FLAG_CLEANUP: YYYY-MM-DD',
},
],
},
};
Rule 2: Flag inventory enforcement
A more sophisticated approach maintains a registry of approved flags:
// eslint-plugin-flags/rules/registered-flags-only.ts
const REGISTERED_FLAGS = new Map([
['checkout-v2', { created: '2025-01-15', expires: '2025-04-15', owner: 'payments-team' }],
['new-search', { created: '2025-03-01', expires: '2025-06-01', owner: 'discovery-team' }],
]);
export default {
meta: { type: 'problem' },
create(context) {
return {
CallExpression(node) {
if (
node.callee.name === 'useFeatureFlag' &&
node.arguments[0]?.type === 'Literal'
) {
const flagKey = node.arguments[0].value;
const registration = REGISTERED_FLAGS.get(flagKey);
if (!registration) {
context.report({
node,
message: `Flag "${flagKey}" is not registered. Add it to the flag registry.`,
});
} else if (new Date(registration.expires) < new Date()) {
context.report({
node,
message: `Flag "${flagKey}" expired on ${registration.expires}. Remove it.`,
});
}
}
},
};
},
};
Tree-sitter detection for TSX/JSX
Tree-sitter provides language-aware parsing that goes beyond what regex or ESLint can achieve. It understands the full syntax tree of TSX and JSX files, making it possible to detect flag patterns with high precision.
Tree-sitter excels at detecting patterns like:
- Feature flag hook calls and their argument values
- Conditional JSX rendering controlled by flag variables
- Flag-gated component props
- HOC wrappers that reference flags
// Tree-sitter query for TSX flag detection (S-expression syntax)
// Finds useFeatureFlag('key') calls
(call_expression
function: (identifier) @func_name
arguments: (arguments
(string (string_fragment) @flag_key))
(#eq? @func_name "useFeatureFlag"))
// Finds ternary expressions using flag variables
(ternary_expression
condition: (identifier) @condition
consequence: (jsx_element) @true_branch
alternative: (jsx_element) @false_branch
(#match? @condition "^(show|enable|is|has|use)"))
This is the approach that tools like FlagShark use to detect flags across TypeScript and React codebases with multi-language accuracy. Tree-sitter grammars understand JSX natively, which means they can distinguish between a flag check in a conditional render and the same variable name used in an unrelated context.
TypeScript compiler API for deep analysis
For teams that need deeper analysis, the TypeScript compiler API can trace flag usage through type inference:
import * as ts from 'typescript';
function findFlagReferences(sourceFile: ts.SourceFile, flagName: string) {
const references: ts.Node[] = [];
function visit(node: ts.Node) {
// Find useFeatureFlag calls
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'useFeatureFlag' &&
node.arguments[0] &&
ts.isStringLiteral(node.arguments[0]) &&
node.arguments[0].text === flagName
) {
references.push(node);
// Trace the variable binding
const parent = node.parent;
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
const varName = parent.name.text;
// Find all references to this variable in the file
findVariableUsages(sourceFile, varName, references);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return references;
}
Safe removal patterns
Removing a stale flag is more than deleting an if statement. In React codebases, safe removal requires a systematic approach that accounts for the flag's full propagation chain.
Step 1: Map the blast radius
Before touching any code, map every file that references the flag:
# Find all direct flag references
rg "useFeatureFlag\(['\"]my-flag['\"]" --type ts --type tsx
# Find prop drilling
rg "showMyFlag|enableMyFlag|myFlagEnabled" --type ts --type tsx
# Find related test files
rg "my-flag" --type ts -g "*test*" -g "*spec*"
# Find Storybook stories
rg "my-flag" --type ts -g "*stories*"
Step 2: Remove from the inside out
Always remove flags starting from the leaf components and working back toward the root. This prevents intermediate states where a parent passes a flag prop that no child consumes.
Before cleanup (leaf component):
interface MetricCardProps {
title: string;
value: number;
showSparkline: boolean; // Flag-driven prop
}
function MetricCard({ title, value, showSparkline }: MetricCardProps) {
return (
<div className="metric-card">
<h3>{title}</h3>
<span>{value}</span>
{showSparkline && <Sparkline data={historicalData} />}
</div>
);
}
After cleanup (flag was permanently enabled):
interface MetricCardProps {
title: string;
value: number;
}
function MetricCard({ title, value }: MetricCardProps) {
return (
<div className="metric-card">
<h3>{title}</h3>
<span>{value}</span>
<Sparkline data={historicalData} />
</div>
);
}
Step 3: Clean up the context provider
If the flag was served through a context provider, remove it from the context type and the provider implementation:
Before:
interface FeatureFlags {
enableNewDashboard: boolean;
enableRealTimeUpdates: boolean; // Stale -- always true
enableDarkMode: boolean;
}
function FeatureFlagProvider({ children }: { children: React.ReactNode }) {
const [flags, setFlags] = useState<FeatureFlags>({
enableNewDashboard: false,
enableRealTimeUpdates: false,
enableDarkMode: false,
});
useEffect(() => {
fetchFlags().then(setFlags);
}, []);
return (
<FeatureFlagContext.Provider value={flags}>
{children}
</FeatureFlagContext.Provider>
);
}
After:
interface FeatureFlags {
enableNewDashboard: boolean;
enableDarkMode: boolean;
}
function FeatureFlagProvider({ children }: { children: React.ReactNode }) {
const [flags, setFlags] = useState<FeatureFlags>({
enableNewDashboard: false,
enableDarkMode: false,
});
useEffect(() => {
fetchFlags().then(setFlags);
}, []);
return (
<FeatureFlagContext.Provider value={flags}>
{children}
</FeatureFlagContext.Provider>
);
}
Step 4: Remove dead components and types
After removing the flag checks, entire components and type definitions may become unused:
// DELETE: This component was only used in the flag-off branch
// function LegacyMetrics() { ... }
// DELETE: This type was only used by LegacyMetrics
// interface LegacyMetricsProps { ... }
// DELETE: This utility was only called by the legacy path
// function formatLegacyMetric(value: number): string { ... }
TypeScript's compiler will help here. After removing the flag checks, run tsc --noEmit and look for unused imports and unreferenced declarations. Enable noUnusedLocals and noUnusedParameters in your tsconfig.json to catch orphaned references:
{
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Step 5: Update tests
This is where most teams underestimate the effort. Every flag creates test scenarios for both branches, and removing a flag means collapsing those scenarios:
Before:
describe('Dashboard', () => {
it('renders new metrics when flag is enabled', () => {
renderWithFlags(<Dashboard />, { 'new-metrics': true });
expect(screen.getByTestId('new-metrics-panel')).toBeInTheDocument();
});
it('renders legacy metrics when flag is disabled', () => {
renderWithFlags(<Dashboard />, { 'new-metrics': false });
expect(screen.getByTestId('legacy-metrics-panel')).toBeInTheDocument();
});
it('shows sparklines in new metrics mode', () => {
renderWithFlags(<Dashboard />, { 'new-metrics': true });
expect(screen.getAllByTestId('sparkline')).toHaveLength(4);
});
});
After:
describe('Dashboard', () => {
it('renders metrics panel', () => {
render(<Dashboard />);
expect(screen.getByTestId('new-metrics-panel')).toBeInTheDocument();
});
it('shows sparklines', () => {
render(<Dashboard />);
expect(screen.getAllByTestId('sparkline')).toHaveLength(4);
});
});
Notice that the tests no longer need renderWithFlags for this specific flag, the legacy branch tests are removed entirely, and test descriptions are simplified to reflect the permanent behavior.
Building a cleanup workflow
The flag cleanup checklist
For each stale flag removal, follow this sequence:
| Step | Action | Verification |
|---|---|---|
| 1 | Map all references (components, props, types, tests) | rg search returns complete list |
| 2 | Remove flag from leaf components first | Leaf components render correctly |
| 3 | Remove flag props from parent components | No TypeScript errors at prop sites |
| 4 | Clean up context provider | Context type matches remaining flags |
| 5 | Delete dead components and types | tsc --noEmit passes clean |
| 6 | Update/remove affected tests | Test suite passes with fewer tests |
| 7 | Remove flag from provider configuration | Flag no longer fetched from service |
| 8 | Update Storybook stories | Stories render without flag dependency |
Automating flag cleanup tracking
The most effective teams do not rely on memory or goodwill for flag cleanup. They automate tracking with clear ownership and deadlines.
Approach 1: Flag metadata in code
// flags.ts -- Single source of truth
export const FLAG_REGISTRY = {
'checkout-v2': {
owner: 'payments-team',
created: '2025-01-15',
expires: '2025-04-15',
jiraTicket: 'PAY-1234',
description: 'New multi-step checkout flow',
},
'search-redesign': {
owner: 'discovery-team',
created: '2025-03-01',
expires: '2025-06-01',
jiraTicket: 'DISC-567',
description: 'Redesigned search results page',
},
} as const satisfies Record<string, FlagMetadata>;
export type FlagKey = keyof typeof FLAG_REGISTRY;
This approach ties flag metadata directly to the codebase, making it visible in code review and searchable with standard tools.
Approach 2: CI pipeline enforcement
# .github/workflows/flag-hygiene.yml
name: Flag Hygiene Check
on: [pull_request]
jobs:
check-flags:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for expired flags
run: |
npx ts-node scripts/check-expired-flags.ts
- name: Verify flag registry
run: |
npx ts-node scripts/verify-flag-references.ts
Approach 3: Automated detection with FlagShark
For teams that want detection and cleanup handled automatically, FlagShark integrates directly with GitHub to monitor PRs for flag additions and removals. It tracks the full lifecycle of every flag across your TypeScript and React codebase, using tree-sitter parsing to detect flag patterns in TSX, JSX, and TypeScript files. When a flag becomes stale, it can automatically generate a cleanup PR with the removal changes mapped out.
Preventing flag debt from accumulating
The best cleanup strategy is prevention. These practices keep flag debt from reaching critical mass in React codebases:
1. Enforce flag expiration at creation time. Every useFeatureFlag call should have an associated expiration date. If your ESLint config enforces this, flags cannot enter the codebase without a cleanup plan.
2. Create the cleanup PR alongside the feature PR. When you open a PR that adds a flag, draft a second PR that removes it. The cleanup PR will conflict once the feature merges, but it serves as living documentation of exactly what needs to change.
3. Limit flag scope in the component tree. Keep flag checks as close to the leaf components as possible. A flag consumed in a top-level layout component will propagate through the entire application. A flag consumed in a single card component affects only that card.
4. Use TypeScript's type system as an ally. Define flag keys as a union type derived from your registry. When you remove a flag from the registry, TypeScript will surface every reference as a compile error:
type FlagKey = 'checkout-v2' | 'search-redesign' | 'dark-mode';
function useFeatureFlag(key: FlagKey): boolean {
// ...
}
// When 'checkout-v2' is removed from the union,
// every call site using it becomes a type error
5. Track flag metrics. Monitor how many flags are active, how old the oldest flag is, and how many cleanup PRs are pending. Make these numbers visible in team dashboards and retrospectives.
| Metric | Healthy Target | Warning Threshold |
|---|---|---|
| Active flags per 10k LOC | < 5 | > 15 |
| Average flag age | < 30 days | > 60 days |
| Flags without cleanup ticket | 0 | > 3 |
| Pending cleanup PRs | < 5 | > 10 |
| Flag-related test scenarios | < 10% of suite | > 25% of suite |
Stale feature flags in TypeScript and React codebases are not a hypothetical risk -- they are a measurable drag on developer productivity, code quality, and application performance. The patterns described in this guide give you a concrete path from detection to removal to prevention. The teams that treat flag cleanup as a first-class engineering concern ship faster, onboard engineers more quickly, and spend their time building features instead of archaeology. Start with an audit of your current flags, map their blast radius, and begin removing from the leaves inward. Your future self -- and every engineer who joins your team after you -- will thank you.