Experiments
Feature Flags

Feature flags in React with GrowthBook

A graphic of a bar chart with an arrow pointing upward.

Reading a feature flag in React is one line. Everything around that line is where teams get into trouble.

const showNewCheckout = useFeatureIsOn("new-checkout") looks trivial, and the rendering part is. The hard parts are the ones you don't see in that line: deciding who sees the flag, rolling it out without breaking production, turning it off in seconds when something goes wrong, avoiding the flash of the wrong UI on first paint, and deleting the flag before it rots into permanent branching logic. This guide walks through how feature flags actually behave in a React app, where the common do-it-yourself approach runs out of road, and how to wire up a production-ready setup with GrowthBook's React SDK.

Why React makes feature flags easy to start and easy to get wrong

React's component model makes the rendering side of feature flags almost free. You have a boolean, you render one branch or the other, and you move on. That low friction is exactly why React codebases tend to accumulate flags faster than the team's discipline around them.

The problem is that a feature flag is not really a UI concern. It's a release-control mechanism that happens to surface in the UI. Treating it as "just an if statement" is how you end up with flicker, leaked code paths, and flags nobody remembers the purpose of.

Feature flags are not environment variables

A first instinct in React is to reach for process.env.REACT_APP_NEW_CHECKOUT or a NEXT_PUBLIC_ variable. That's config, not a flag. An environment variable is decided at build time and baked into the bundle, so changing it means a rebuild and redeploy, it's identical for every user, and you can't flip it off mid-incident. A feature flag is decided at runtime, can differ per user, and changes without shipping code. The test is simple: if a value never needs to change without a deploy and is the same for everyone, it's config; if it needs targeting, a gradual rollout, or a fast off switch, it's a flag.

The flicker problem is structural, not cosmetic

The most common React-specific failure is the flash of incorrect content. It happens when flag values resolve asynchronously in the browser: the first render uses a default (or stale) value, the flag config arrives a beat later, and the component re-renders into a different state. The user sees the old button, then the new one.

This gets worse with server-side rendering. The server has no access to localStorage and may not know the visitor's identity, so it renders one variant while the client hydrates into another. The fix is structural: resolve the flag before the content paints, either by blocking briefly on the client or by moving flag resolution to the server so the HTML ships already-decided. A flag system that ignores this forces every team to rediscover the same bug.

"It's just an if statement" hides the real requirements

A flag you can flip in code is the smallest part of the job. A real release needs targeting (this flag is on for internal users and 5% of everyone else), instant rollback (turn it off without a deploy), an audit trail (who changed it, when), and cleanup (remove it once the feature ships). Martin Fowler's canonical writeup on feature toggles makes the same point: toggles are useful, but they carry a real carrying cost, and the management around them matters more than the branch itself.

The homegrown approach and where it breaks

Most React teams start by building flags themselves, and the idiomatic version uses React context plus a hook. It looks clean:

import { createContext, useContext, useEffect, useState } from "react";

const FlagContext = createContext({});

export function FlagProvider({ children }) {
const [flags, setFlags] = useState(null);

useEffect(() => {
fetch("/api/flags")
.then((res) => res.json())
.then(setFlags);
}, []);

return (
<FlagContext.Provider value={flags ?? {}}>
{children}
</FlagContext.Provider>
);
}

export const useFlag = (name) =>
useContext(FlagContext)[name] ?? false;

This is a reasonable starting point, and useContext is the right primitive for avoiding prop drilling. You can change flag values from a config endpoint without redeploying the frontend, which already beats hardcoded constants.

But notice what you now own. The flags ?? {} fallback means every component renders the off state until the fetch resolves, so you've built the flicker bug in by default. And the config endpoint only returns a flat map of booleans. The moment you need anything beyond on/off, you're writing infrastructure:

  • Targeting: "on for users at Acme, off otherwise" means shipping rules and user attributes to the client and evaluating them safely.
  • Percentage rollout: consistent bucketing so the same user always lands in the same group across sessions and devices.
  • Kill switch: a guaranteed-fast off path, which a 30-second config-cache TTL is not.
  • Audit and approvals: who turned this on in production, and could they?
  • Cleanup: finding every reference to a flag months later so you can delete it.
  • SSR: resolving values on the server so the markup is correct on first paint.

None of these are hard individually. Together they're a platform, and maintaining that platform is not why your team exists. This is the same tradeoff the React community keeps landing on: a context-based custom solution is the right pattern, but the backend behind it is what you actually want to outsource.

Setting up GrowthBook feature flags in React

GrowthBook keeps the context pattern you'd build anyway and replaces the part you don't want to maintain. The SDK evaluates flags locally in the browser using rules it fetches once, so a normal flag check doesn't make a network request, while the targeting, rollout, and rollback logic lives in a UI and an API you don't have to build. GrowthBook is also open source and self-hostable, which matters if you'd rather not send any user data to a third-party service.

Install and initialize the SDK

Install the React package:

npm install --save @growthbook/growthbook-react

Then create a single GrowthBook instance for your app. The clientKey comes from an SDK Connection you create in GrowthBook, and apiHost points at the CDN (or your self-hosted proxy):

import { GrowthBook } from "@growthbook/growthbook-react";

export const gb = new GrowthBook({
apiHost: "https://cdn.growthbook.io",
clientKey: "sdk-abc123",
enableDevMode: process.env.NODE_ENV === "development",
trackingCallback: (experiment, result) => {
// forward exposure events to your analytics tool
analytics.track("Experiment Viewed", {
experimentId: experiment.key,
variationId: result.key,
});
},
});

// load feature definitions and keep them fresh over a stream
gb.init({ streaming: true });

The full set of constructor and init options is documented in the React SDK reference. enableDevMode turns on the DevTools extension so you can flip flags locally without touching the dashboard.

Wrap your app with GrowthBookProvider

GrowthBookProvider puts the instance on React context so any component can read flags. As of version 1.0.0 you always pass a real instance, never null:

import { GrowthBookProvider } from "@growthbook/growthbook-react";
import { gb } from "./growthbook";

export default function App() {
return (
<GrowthBookProvider growthbook={gb}>
<Routes />
</GrowthBookProvider>
);
}

Read a flag in a component

Now the one-liner from the intro works, and you have a few ways to use it depending on the shape of the value. useFeatureIsOn returns a boolean, useFeatureValue returns a typed value with a fallback, and IfFeatureEnabled is a render wrapper for the common conditional case:

import {
useFeatureIsOn,
useFeatureValue,
IfFeatureEnabled,
} from "@growthbook/growthbook-react";

function Checkout() {
const useNewFlow = useFeatureIsOn("new-checkout");
const ctaText = useFeatureValue("checkout-cta", "Buy now");

return (
<>
{useNewFlow ? <NewCheckout cta={ctaText} /> : <LegacyCheckout />}

<IfFeatureEnabled feature="promo-banner">
<PromoBanner />
</IfFeatureEnabled>
</>
);
}

The fallback in useFeatureValue is not optional politeness. It's the value your UI renders if the feature definitions haven't loaded or the flag doesn't exist, so choose the safe default deliberately.

Identify users with attributes

Targeting and consistent bucketing both depend on attributes: the facts about the current user that GrowthBook evaluates rules against. Set them once you know who the user is, and update them when that changes:

import { useEffect } from "react";
import { gb } from "./growthbook";

function useIdentifyUser(user) {
useEffect(() => {
gb.setAttributes({
id: user?.id ?? anonymousId,
company: user?.company,
plan: user?.plan,
betaTester: user?.flags?.includes("beta") ?? false,
});
}, [user]);
}

For anonymous visitors, generate a stable id once and persist it (the official Create React App guide uses nanoid stored in localStorage) so the same person stays in the same rollout group across visits. With attributes in place, you can define a rule in GrowthBook like "on for betaTester = true, plus 10% of plan = pro" entirely through targeting conditions, no code change required.

If you target flags by URL and use a client-side router, call gb.setURL(window.location.href) on navigation. React Router and similar libraries change the URL without a full page load, so the SDK won't re-evaluate URL-based rules unless you tell it the route changed.

Handling the hard parts: flicker, loading, and SSR

This is where a platform earns its keep, because these are the problems the homegrown version pushed onto you.

Avoid flag flicker with FeaturesReady

For client-rendered apps, the simplest fix for flicker is to not render flag-dependent UI until the definitions are loaded. FeaturesReady does exactly that, with a timeout so a slow network doesn't hang the page:

import { FeaturesReady } from "@growthbook/growthbook-react";

<FeaturesReady timeout={500} fallback={<DashboardSkeleton />}>
<Dashboard />
</FeaturesReady>;

After the timeout, GrowthBook renders your children using whatever values are available (including fallbacks), so you trade a brief skeleton for a stable first paint.

Resolve flags on the server to kill flicker entirely

A blocking skeleton hides flicker; server-side resolution removes it. If you're on Next.js or another SSR setup, fetch the feature payload on the server and hydrate the client synchronously with initSync, so the markup is already correct before React touches it:

import { GrowthBook } from "@growthbook/growthbook-react";

// on the server, fetch the payload once, then pass it to the client
gb.initSync({ payload });

GrowthBook's Next.js and Vercel guide covers the full server-and-client key pattern. The principle is the one every SSR flag integration converges on: decide the variant before the HTML leaves the server.

Stream updates for instant kill switches

A kill switch is only useful if it's actually fast. Because gb.init({ streaming: true }) keeps a live connection open, flipping a flag off in the dashboard pushes the new value to connected clients in near real time instead of waiting for the next poll or page load. That turns "turn off the broken feature" from a redeploy into a toggle. Pair it with environment-specific values so production and staging can be killed independently.

Keep flags type-safe with TypeScript

Stringly-typed flag keys are a quiet source of bugs: one typo and your check silently returns the default forever. GrowthBook can generate TypeScript definitions from your flags so keys and value types are checked at compile time:

const isDarkMode = useFeatureIsOn<AppFeatures>("dark_mode");

A misspelled key or a wrong value type becomes a build error instead of a production surprise.

Test components without faking the whole SDK

Components behind a flag still need tests, and you don't want those tests hitting the network or depending on dashboard state. Because the SDK can initialize synchronously from a payload, you can hand each test its own GrowthBook instance with the flags set exactly how the test needs them:

import { GrowthBook, GrowthBookProvider } from "@growthbook/growthbook-react";
import { render, screen } from "@testing-library/react";

function renderWithFlags(ui, features) {
const gb = new GrowthBook();
gb.initSync({ payload: { features } });
return render(
<GrowthBookProvider growthbook={gb}>{ui}</GrowthBookProvider>
);
}

test("shows the new checkout when the flag is on", () => {
renderWithFlags(<Checkout />, {
"new-checkout": { defaultValue: true },
});
expect(screen.getByTestId("new-checkout")).toBeInTheDocument();
});

There are no mocks and no network calls, and the same useFeatureIsOn runs in the test that runs in production. That means you can test both branches of a flag by changing one argument instead of stubbing modules.

Patterns that keep flags from becoming tech debt

Every flag is debt until it's removed. The goal isn't to collect flags; it's to use them as a temporary tool and clean up after. A few habits keep that under control.

Target before you ramp. A random percentage is wrong for anything that can lock someone out: billing flows, permission changes, data migrations. Start those with explicit targeting and safe rollouts to internal users or a named cohort, then widen once you trust it. Save percentage rollouts for low-blast-radius UI changes.

Put the kill switch at the right level. A presentational flag that only hides one button while the underlying requests, effects, and routes still fire is not a kill switch. The off path should disable the whole feature flow, not just its entry point.

Plan the deletion when you create the flag. Use a clear naming convention and lean on stale flag detection and code references to find every place a flag is read once the feature is fully shipped, so removing it is a chore and not an archaeology project.

The decision of what kind of flag you're creating drives most of the rest:

Flag purposeLifespanDefault rollout patternCleanup expectation
Release toggle (ship dark, then reveal)Days to weeksInternal, then percentage rampRemove after full rollout
Operational kill switchLong-livedOn, flipped off under incidentKeep, review periodically
Experiment (measure impact)Length of the testEven split across variationsRemove after the decision
Permission or entitlementPermanentTargeted by plan or roleKeep, treat as config

Naming the purpose up front tells you the lifespan, the rollout shape, and whether the flag should ever be deleted.

From feature flag to experiment

The reason flags and experiments belong in the same system is that they're the same mechanism with measurement attached. Once a flag is splitting traffic between two code paths, recording which users saw which variant and tying that to your metrics turns a release into a feature flag experiment. That's what the trackingCallback in the setup above is for: it fires when a user is bucketed, and you forward the exposure event to your analytics.

Concretely: hold new-checkout at an even split, define a conversion metric on the orders table you already have, and GrowthBook compares completion rates between the users who saw each variant. The flag you shipped for safety becomes the experiment that tells you whether the new flow was actually better, or whether you just shipped a redesign that moved nothing.

This is where GrowthBook's architecture shows up. Because it runs experiment analysis against your existing data warehouse, the same flag you used to ship safely can be measured on metrics you already trust, without copying behavioral data into a separate analytics silo. The bigger payoff is using the same useFeatureIsOn you started with to also answer "did the new checkout actually move conversion?" through GrowthBook's experimentation tools, rather than wiring up a second SDK for testing.

What to build next

If you're starting today, the smallest useful step is also the one that pays off fastest: wrap your app in GrowthBookProvider, replace one hardcoded constant with useFeatureIsOn, and ship a flag you can turn off without a deploy. Add FeaturesReady or server-side resolution the first time you see flicker, generate TypeScript types before your flag count gets into the dozens, and decide each flag's lifespan the moment you create it.

The flags you can delete are the ones that paid off. The ones you can't are the ones that skipped the planning. You can try GrowthBook's feature flags for free and start with a single flag in your React app.

Table of Contents

Related Articles

See All Articles
Experiments

Type I vs Type II error: key differences with examples

Jun 17, 2026
x
min read
Experiments

Type I error explained: definition, examples, and how to reduce it

Jun 16, 2026
x
min read
Experiments

Multivariate testing vs A/B testing: key differences explained

Jun 16, 2026
x
min read

Ready to ship faster?

No credit card required. Start with feature flags, experimentation, and product analytics—free.

Simplified white illustration of a right angle ruler or carpenter's square tool.White checkmark symbol with a scattered pixelated effect around its edges on a transparent background.