WebMriya
Back to articles

React Feature Flags Without Conditional Chaos

A practical React architecture for feature flags that keeps rollout logic isolated, testable, type-safe, and removable when an experiment ends.

React
TypeScript
Frontend Architecture
Software Architecture
W
Frontend Architecture & Legacy Modernization
October 10, 2026 · 5 min read

A new feature is ready, but the product team wants it enabled only for internal users, one customer segment, and a small percentage of traffic. The quickest solution is usually to add a few environment checks and scatter conditions through the component tree. Three months later, nobody knows which flag is still active, which combinations are valid, or whether removing the experiment will break an unrelated screen.

Feature flags are not difficult because a boolean is difficult. They become difficult when rollout policy, UI branching, permissions, analytics, and legacy cleanup all become part of ordinary component rendering. The goal is not to avoid conditional rendering. The goal is to keep product decisions from becoming invisible architecture.

Separate flag evaluation from UI rendering

A component should ideally ask a focused question such as "should the redesigned checkout be shown?" It should not know whether the answer came from an environment variable, a remote configuration service, a user segment, or a percentage rollout. That decision belongs at a boundary.

Start with a typed flag model and one evaluation function. Keep raw configuration out of JSX, and return a stable object that the rest of the application can consume.

ts
type FeatureFlags = {
  redesignedCheckout: boolean;
  bulkActions: boolean;
};

type FlagContext = {
  userId: string;
  plan: "free" | "pro" | "enterprise";
};

export function evaluateFlags(context: FlagContext): FeatureFlags {
  return {
    redesignedCheckout: context.plan !== "free",
    bulkActions: context.plan === "enterprise"
  };
}

This function is deliberately boring. That is useful: it can be tested without rendering React, and the policy is visible in one place. If the real application uses a remote service, the adapter can translate remote data into this same typed shape.

Do not treat every condition as a feature flag

Several different kinds of decisions often get mixed together under the word flag. They have different owners and different lifetimes.

DecisionBest locationTypical lifetime
Feature rolloutFlag evaluation layerDays to months
User permissionAuthorization or capability layerLong-lived
Loading or error stateComponent stateSeconds to minutes
Experiment variantExperiment assignment serviceUntil the experiment ends
Platform capabilityRuntime capability checkUntil browser or device support changes

A permission check answers whether a user may perform an action. A feature flag answers whether a product capability should be exposed at all. They can both affect the UI, but combining them into one generic `isEnabled` value makes auditing much harder.

Prefer a small application boundary

Passing flags through many component layers creates prop noise. Reading a global flag service inside every component creates hidden dependencies. A small provider or application hook gives components a predictable interface while keeping evaluation near the application boundary.

tsx
const FeatureFlagsContext =
  createContext<FeatureFlags | null>(null);

export function useFeatureFlags() {
  const flags = useContext(FeatureFlagsContext);
  if (!flags) {
    throw new Error("FeatureFlagsProvider is missing");
  }
  return flags;
}

function CheckoutPage() {
  const { redesignedCheckout } = useFeatureFlags();

  return redesignedCheckout
    ? <RedesignedCheckout />
    : <LegacyCheckout />;
}

The hook is not meant to hide every product decision. It gives the application one place to obtain the current flag state and makes missing setup fail loudly. For a complex application, the provider can also expose loading and synchronization status, but those states should be explicit rather than represented by undefined values scattered across the tree.

Make rollout states explicit

Remote flags introduce a timing problem. The application may render before the configuration request completes. If the default is false, users may briefly see the old interface and then the new one. If the default is true, an unfinished feature may be exposed accidentally.

  • Use a safe default when the feature has security, data integrity, or billing implications.
  • Avoid rendering a sensitive branch until the required flag data is known.
  • Decide whether a loading state, server-side evaluation, or a stable bootstrap payload is appropriate.
  • Keep flag changes synchronized with analytics so an event can be interpreted against the active variant.

The right choice depends on the feature. A cosmetic navigation change can tolerate a fallback. A destructive bulk action should not appear merely because a configuration request failed.

Test decisions, not just branches

A common test suite checks that both components render, but never checks whether the correct audience receives each variant. The more valuable tests exercise the evaluation policy separately from the rendering contract.

  1. Test flag evaluation for each supported plan, segment, or rollout rule.
  2. Test that the provider exposes the evaluated result to consumers.
  3. Test the enabled and disabled rendering paths for important user journeys.
  4. Test the unknown or loading state when configuration is remote.
  5. Test the removal path before deleting the flag and its fallback.

This is especially important when flags interact. Two individually valid flags can produce an invalid combination. If the combinations matter, model them as a state or policy rather than allowing every component to infer the relationship independently. The explicit-state approach described in React State Machines: Replace Boolean Flags With Explicit UI States is useful when rollout states become more than a handful of independent switches.

Plan removal before adding the flag

A feature flag is temporary architecture unless it is deliberately turned into permanent configuration. Every new flag should have an owner, an intended removal condition, a creation date, and a short note about the branches it controls. A simple inventory prevents forgotten flags from becoming permanent product behavior.

  • Name flags after the capability, not the project ticket or release date.
  • Avoid negative names such as `disableNewCheckout`; double negatives make conditions harder to review.
  • Keep the old branch reachable until telemetry and acceptance checks are complete.
  • Remove the flag, dead branch, tests, configuration, and documentation together.
  • Search for the flag name across the repository before declaring cleanup complete.

The cleanup step is not merely code tidying. It reduces the number of possible UI states and makes future changes easier to reason about. If a flag cannot be removed because two versions must coexist indefinitely, it may be a product capability or permission rather than a temporary rollout flag.

Use flags as boundaries, not escape hatches

Feature flags work well when they isolate a change that can be enabled, observed, and removed. They work poorly when they become a way to merge two incompatible architectures inside every component. Keep the branch close to a meaningful application boundary, keep the policy typed, and make the lifecycle visible.

For teams maintaining a large React codebase, this discipline is closely related to legacy modernization: reducing hidden coupling, making runtime behavior explicit, and removing obsolete paths safely. When the application has many independent product surfaces, a complex web application team can help establish these boundaries without turning every release into a rewrite.

Frequently asked questions

Should feature flags live in React context?

Context is a useful delivery mechanism for evaluated flags, especially when many components need them. The evaluation policy should remain outside the components so it can be tested and replaced independently.

Should a feature flag also handle user permissions?

Usually not. Rollout controls exposure of a capability, while permissions control what a user is allowed to do. Keeping them separate makes security reviews and future cleanup clearer.

When should a feature flag be removed?

Remove it when rollout is complete, the old branch is no longer needed, and telemetry or acceptance checks confirm the new path. Delete the flag, fallback branch, tests, and configuration as one change.