Most React feature-flag setups are the same thing: a useFlag() call sprinkled through JSX wherever someone needed to hide a button.
It works in the demo. Then it quietly costs you three things: a flash of the wrong content on first paint, a re-render storm every time one flag flips, and a graveyard of dead flags nobody dares delete.
This post is about the architecture that avoids all three: where evaluation belongs, how to keep flags out of your render graph, and where each flag should live so the pile stops rotting.
Classify the flag before you write a line of code
Martin Fowler's taxonomy splits toggles into four kinds, and the split is not academic. It decides lifespan and where the flag lives.
- Release toggles: enable trunk-based dev, live for a week or two, static per deploy.
- Experiment toggles: A/B tests, medium-lived, dynamic per request by cohort.
- Ops toggles: kill switches and graceful degradation, some become long-lived.
- Permission toggles: premium, beta, internal access, often long-lived for years, always per request.
Two axes drive this: longevity (days to years) and dynamism (static deployment-wide to dynamic per-request). The common mistake is mixing categories with different dynamism at one toggle point, so a static release flag and a per-request permission check read through the same useFlag('checkout_v2') call. You can't reason about either.
Decide the category first. It tells you whether the flag needs server evaluation and when it gets deleted.
Stop scattering magic strings across JSX
Here is the setup we keep finding. The rollout logic lives inline, and it uses Math.random().
// Looks fine. Ships a different variant on every render.
function CheckoutButton() {
const isNew = Math.random() < 0.5;
return isNew ? <CheckoutV2 /> : <CheckoutV1 />;
}
Two bugs. The user flips variants on every re-render, and no bug report is reproducible without a stable assignment. Percentage rollouts need sticky bucketing: hash a stable identifier plus the flag key, never a random number.
Centralize the decision in one module so JSX only ever reads a boolean.
// featureDecisions.ts
import { hash } from "./hash"; // any stable 32-bit string hash
export function isEnabled(userId: string, key: string, percent: number) {
const bucket = hash(`${userId}:${key}`) % 100;
return bucket < percent;
}
export function checkoutDecisions(userId: string) {
return { showCheckoutV2: isEnabled(userId, "checkout_v2", 20) };
}
Now CheckoutButton reads showCheckoutV2 and knows nothing about hashing or cohorts. Deleting the flag means deleting one field in one file instead of grepping a magic string across forty components. That is decoupling the decision from the toggle point.
Why one flag flip re-renders your whole tree
The default React pattern is a FeatureFlagProvider at the root and a typed useFeatureFlag('checkout_v2') hook. That part is right. The trap is putting every flag in one context object.
Context does not do partial updates. If all flags share one value and one flag changes, every consumer re-renders, including components reading an unrelated flag. In a large app that is hundreds of components re-running because one boolean flipped. This is the context-and-rerenders problem in a feature-flag costume.
Two fixes: split flags into separate contexts by domain so a checkout flag never touches the nav, or expose a selector-style hook so a component subscribes to one flag, not the whole bag. Either way, evaluate targeting once at the root and keep percentage logic out of the render path.
Skip the withFeatureFlag() HOC for anything but route gating. It adds a wrapper that is harder to trace in DevTools than a hook.
The hydration flash is a server-vs-client problem
Fetch a flag on the client and the server renders the default variant, then the client mounts, reads the flag, and swaps the content. The user sees the flash. suppressHydrationWarning only silences the console warning; the content still jumps.
// Client-fetched flag: server sends the default, client swaps after mount.
function Banner() {
const [promo, setPromo] = useState(false);
useEffect(() => {
fetch("/api/flags/promo").then(r => r.json()).then(d => setPromo(d.on));
}, []);
return promo ? <PromoBanner /> : <StandardBanner />;
}
Read the flag on the server instead. Cookies ride in the request headers, so the server reads the value during the initial render and outputs matching HTML on first paint with zero hydration overhead.
// Server component: correct HTML on first paint, no flash.
import { cookies } from "next/headers";
export default async function Banner() {
const promo = (await cookies()).get("promo")?.value === "on";
return promo ? <PromoBanner /> : <StandardBanner />;
}
And be blunt about security: pricing, entitlements, and permission toggles must evaluate on the server. Never trust local JavaScript for authorization. Client evaluation is fine for a promo banner, not for who sees the paid tier.
Framework-native flags are the direction of travel
Vercel Flags reached general availability in April 2026, and the framework-native Flags SDK (the flags npm package) ships a different pattern. Each flag becomes a callable function declared in one file and awaited in a server component.
// flags.ts, then: const enabled = await checkoutV2()
import { flag } from "flags/next";
import { vercelAdapter } from "@flags-sdk/vercel";
export const checkoutV2 = flag({
key: "checkout_v2",
adapter: vercelAdapter, // 4.2.0+ takes the factory by reference
decide: ({ entities }) => entities?.tier === "pro",
});
The design pushes you toward the safe path: server-only evaluation kills the client spinner, and no call-site arguments keep every reference consistent. Precompute then routes users in middleware so every page stays static.
One migration gotcha: the called form (vercelAdapter()) still works on 4.2.0 and up alongside the newer by-reference form, so a half-migrated codebase compiles and hides the inconsistency.
My stance, and you can push back: for anything security or SEO sensitive, server-evaluated framework-native flags are now the default, and client-side useFlag is the exception. The honest cost is coupling to a framework and often a vendor. Use the Flags SDK OpenFeature adapter (the CNCF vendor-neutral standard) so your call sites survive a swap. The static output and zero flicker are worth it.
Treat flags as inventory or drown in them
Every flag doubles the states your app can be in. Ten flags is a thousand combinations, and your snapshot tests cover only the path they recorded. The opposite-flag states ship untested.
Fowler's line is worth internalizing: treat toggles as inventory that carries a cost, and keep it as low as possible. Old flags don't just linger in code, they rot into conditionals, comments, dashboards, and runbooks that no longer match reality.
The rule that holds: no flag ships without an owner, a purpose, and a removal date on day one. Release toggles get a two-week expiry. A flag with no removal plan is just technical debt with a nicer name.
Classify it, evaluate it on the server when it matters, keep it out of the render path, and delete it on schedule. Do that and flags stay what they were meant to be: a way to ship faster, not a slow leak in your codebase.