React Feature Flag Best Practices and Patterns
Feature flags are powerful, but without discipline, they become technical debt. I've inherited codebases with 200+ flags, many from experiments from 2 years ago, no one remembering what they controlled. This article covers naming conventions, safe rollout strategies, cleanup workflows, and anti-patterns to avoid.
In 2022, I was asked to investigate why a checkout bug only appeared for 5% of users. It took 4 hours to find: an old experiment flag buried in conditional logic, still half-rolled out. A good naming and cleanup process would have eliminated it. Let's avoid that chaos.
Naming Conventions: Make Flags Self-Documenting
Flag names should be self-explanatory and indicate their type and lifecycle. Use a consistent naming scheme so developers can guess what a flag does without hunting docs.
Pattern: [type]_[feature]_[variant]
type: release, ops, exp (experiment), perm (permission).feature: the feature being flagged (checkout, dashboard, profile).variant: optional, for multi-variant flags (v2, new_ui, dark_mode).
Examples:
release_checkout_v2— release toggle for checkout redesign.ops_payment_provider_fallback— ops toggle; kill switch for payment.exp_homepage_variant_a— experiment comparing variants A vs B.perm_dark_mode— permission toggle; only for Pro users.ops_database_query_cache— ops toggle; emergency cache to reduce database load.
Good names make code reviews and debugging easier. A developer seeing if (release_checkout_v2) immediately understands the flag's purpose, lifecycle, and urgency.
Safe Rollout Strategy: Percent, Time, Rules
When rolling out a new feature, never go 0% to 100%. Instead, follow a step function: 1%, 10%, 25%, 50%, 100%. At each step, monitor error rates, latency, and business metrics. If something breaks, stop the rollout and investigate.
A typical safe rollout for a checkout redesign:
| Day | Rollout % | Action |
|---|---|---|
| 1 | 1% | Monitor error rates, latency, conversion. Check logs. |
| 2 | 10% | Still good? Proceed. If new bugs appear, rollback to 1%. |
| 3 | 25% | Expand. Monitor session duration, payment success rate. |
| 5 | 50% | Monitor overnight for 24 hours. Check for edge cases. |
| 7 | 100% | Full rollout. Keep the flag live as an ops toggle for 1 week, then delete. |
Formula for monitoring: Error rate should not increase by more than 10%. Latency (p99) should not increase by more than 50 ms. Conversion should not decrease (A/B test with statistical significance).
// Rollout in code: track which percentage the user falls into
import { useFeatureFlag } from './FlagContext';
export function SafeCheckout() {
const checkoutVariant = useFeatureFlag('release_checkout_v2', 'legacy');
// Log which variant the user is in
useEffect(() => {
analytics.track('checkout_variant', { variant: checkoutVariant });
}, [checkoutVariant]);
return checkoutVariant === 'v2' ? <CheckoutV2 /> : <CheckoutLegacy />;
}
Cleanup: Delete Flags When They Reach 100%
A completed flag (100% rollout and stable for 1 week) should be deleted. Leaving it in code creates cognitive load and hides the real codebase behind layers of flags.
Cleanup checklist:
- Flag reached 100% rollout 7+ days ago.
- No critical errors in logs for that period.
- All monitoring queries pass (error rate, latency, business metrics normal).
- Remove the flag from the config service.
- Replace the conditional in code with the always-on branch.
- Delete the dead branch (legacy code).
- Delete the flag constant.
- Update documentation.
Example cleanup commit:
// Before: flag still in code
const Checkout = () => {
const useV2 = useFeatureFlag('release_checkout_v2', false);
return useV2 ? <CheckoutV2 /> : <CheckoutLegacy />;
};
// After: flag cleaned up, always-on branch kept
const Checkout = () => {
return <CheckoutV2 />;
};
Establish a flag cleanup schedule: every 2 weeks, audit all active flags. Flags over 1 month old at 100% are candidates for deletion. Assign cleanup as a low-priority task (good for onboarding or between features).
Dependency Management: Watch for Flag Interactions
When one feature depends on another, you need to be careful. If feature A requires feature B, and feature B's flag is off, feature A should gracefully degrade, not crash.
Anti-pattern: Nested flag checks without fallback.
// Bad: crashes if feature_b is off
if (useFeatureFlag('feature_a')) {
if (useFeatureFlag('feature_b')) {
return <A_B_Together />;
}
// Bug: A expects B but B is off. Undefined behavior.
return <A />; // A may depend on B and crash
}
Pattern: Explicit dependency checks.
// Good: A checks if B is on before using it
export function FeatureA() {
const isAEnabled = useFeatureFlag('release_feature_a', false);
const isBEnabled = useFeatureFlag('release_feature_b', false);
if (!isAEnabled) return null;
if (!isBEnabled) {
return <AStandalone />; // A works without B
}
return <A_B_Together />;
}
Or, define a compound flag in your config service:
{
"flagName": "release_feature_a",
"enabled": true,
"dependencies": ["release_feature_b"],
"fallbackBehavior": "degrade_gracefully"
}
Your backend ensures feature A is only enabled if feature B is also enabled. This avoids bugs in the frontend.
Monitoring and Alerting
Set up dashboards and alerts for flags in the ops category. If an ops toggle is flipped (e.g., ops_payment_provider_fallback turned on), alert the team so they know an incident occurred.
Use your observability stack (e.g., Datadog, New Relic) to track:
- Flag change events: Log every toggle (who, when, why).
- Error rate by flag: If a flag correlates with error spikes, alert.
- Performance by flag: If latency increases after a flag rollout, flag it (pun intended).
Example alert: "If release_checkout_v2 error rate > 5%, page the on-call engineer."
Anti-Patterns to Avoid
1. Flag Proliferation (Too Many Flags)
Don't create a flag for every tiny change. Use flags for risky changes: new algorithms, third-party integrations, UI overhauls. Not for moving a button 10 pixels or tweaking copy.
2. Flags as Configuration
Flags are for enabling/disabling features, not configuring behavior. Use flags for boolean decisions (is feature X on?), not for storing settings (what is the font size?). Settings belong in a config file or database, not a flag system.
3. Forgetting to Remove Dead Code
If a flag is at 100%, delete the legacy branch. Don't leave dead code commented out or in conditional blocks for years. It confuses maintainers and increases bundle size.
4. Not Tracking Which Variant a User Is In
Always log which variant the user sees. This is essential for A/B tests: if you don't track who saw variant A vs B, you can't analyze results. Use your analytics SDK to track flagName and variant on every relevant event.
5. Setting Flags in Multiple Places
If your flag value is stored in both React state and localStorage, and they diverge, you have a bug. Single source of truth: flags live on the server. Cache locally for performance, but the server is authoritative.
Key Takeaways
- Use consistent naming:
type_feature_variant(e.g.,release_checkout_v2). - Roll out in steps: 1%, 10%, 25%, 50%, 100%. Monitor at each step.
- Delete flags when they reach 100% and are stable for 1 week.
- Explicitly handle flag dependencies; don't nest checks without fallback logic.
- Alert on ops-toggle changes and error-rate spikes.
- Avoid flag proliferation, dead code, and tracking issues.
Frequently Asked Questions
How many flags are too many?
Aim for < 50 active flags in production. Anything over 100 is likely debt—a sign that flags aren't being cleaned up. Do a quarterly audit and aggressively delete completed flags.
Should I version my feature flags?
No. Use semantic naming instead (e.g., release_checkout_v2 vs release_checkout_v1). Versioning in the flag name itself makes cleanup harder. Just delete the old flag when you're ready to remove it.
Can I use feature flags for canary deployments?
Yes, but it's a different use case. Canary deployments (where 5% of users get new code before 95% do) are handled by your deployment system, not flags. However, you can use flags to control which code paths run on the new build, enabling a hybrid approach.
How do I test a flag locally?
Mock the flag in your tests. Wrap useFeatureFlag in a test context that returns hardcoded values:
// In your test setup
jest.mock('./FlagContext', () => ({
useFeatureFlag: (name, defaultValue) => {
const testFlags = { release_checkout_v2: true };
return testFlags[name] ?? defaultValue;
},
}));