React Feature Flags: A/B Testing and Progressive Rollouts (2026)
Feature flags (also called feature toggles) let you deploy code without releasing it — you ship behind a flag that is off by default, then gradually turn it on for specific users, cohorts, or percentages of traffic. This decouples deployment from release, enables instant kill switches, and makes A/B testing as simple as reading a boolean. This guide covers the OpenFeature standard, LaunchDarkly integration, building your own lightweight flag system, and the discipline of cleaning up flags after launch.
Table of Contents
Core Concepts
// Four types of feature flags:
//
// 1. Release flags — hide incomplete code until ready
// 2. Experiment flags — A/B test two variants, measure metrics
// 3. Ops flags — runtime kill switches (disable a feature under load)
// 4. Permission flags — gate features by plan/role/cohort
//
// Flag evaluation takes a context (who is asking) and returns a value:
// - boolean (on/off)
// - string (which variant: 'control' | 'treatment')
// - number (e.g. rate limit tier)
// - object (complex config)
//
// Key principle: evaluation MUST be fast (cached, local)
// Never block render on a remote flag call — fetch flags at app init,
// cache them, and serve from cache on every component render.
OpenFeature SDK
npm install @openfeature/react-sdk @openfeature/web-sdk
// OpenFeature is a vendor-neutral CNCF standard for feature flags.
// You pick any backend provider (LaunchDarkly, Unleash, Flagsmith, etc.)
// and your React code stays the same.
// src/flags/flagProvider.ts
import { OpenFeature } from '@openfeature/web-sdk'
import { LaunchDarklyClientProvider } from '@launchdarkly/openfeature-web-provider'
export async function initFlags(userContext: { key: string; email: string; plan: string }) {
const provider = new LaunchDarklyClientProvider(import.meta.env.VITE_LD_CLIENT_ID)
await OpenFeature.setProviderAndWait(provider)
// Set evaluation context — all flag evaluations use this
OpenFeature.setContext({
targetingKey: userContext.key,
email: userContext.email,
plan: userContext.plan,
})
}
// main.tsx — initialize before rendering
import { OpenFeatureProvider } from '@openfeature/react-sdk'
async function main() {
await initFlags({ key: user.id, email: user.email, plan: user.plan })
createRoot(document.getElementById('root')!).render(
<OpenFeatureProvider>
<App />
</OpenFeatureProvider>
)
}
main()
// Component — useFlag hook from OpenFeature
import { useBooleanFlagValue, useStringFlagValue, useNumberFlagValue } from '@openfeature/react-sdk'
function NewCheckout() {
// useBooleanFlagValue(flagKey, defaultValue)
const showNewCheckout = useBooleanFlagValue('new-checkout-v2', false)
const checkoutVariant = useStringFlagValue('checkout-layout', 'control')
const itemLimit = useNumberFlagValue('cart-item-limit', 50)
if (!showNewCheckout) return <LegacyCheckout />
return (
<div>
{checkoutVariant === 'treatment' ? <CheckoutV2 /> : <CheckoutV1 />}
<p>Max items: {itemLimit}</p>
</div>
)
}
LaunchDarkly Integration
npm install launchdarkly-react-client-sdk
// Direct LaunchDarkly integration (without OpenFeature abstraction)
import { withLDProvider, useFlags, useLDClient } from 'launchdarkly-react-client-sdk'
// Wrap your app
const App = withLDProvider({
clientSideID: import.meta.env.VITE_LD_CLIENT_ID,
reactOptions: { useCamelCaseFlagKeys: true },
// Initial user context — update when user logs in
context: {
kind: 'user',
key: 'anonymous',
anonymous: true,
},
})(AppRoot)
// After login — identify the real user
function useIdentifyUser() {
const ldClient = useLDClient()
return async (user: { id: string; email: string; plan: 'free' | 'pro' | 'enterprise' }) => {
await ldClient?.identify({
kind: 'user',
key: user.id,
email: user.email,
custom: { plan: user.plan },
})
}
}
// Read all flags at once
function FeatureGatedDashboard() {
const flags = useFlags()
// flags is an object: { newDashboard: true, betaAnalytics: false, maxProjects: 10, ... }
return (
<div>
{flags.newDashboard && <NewDashboardWidget />}
{flags.betaAnalytics && <AnalyticsPanel />}
<p>Projects allowed: {flags.maxProjects}</p>
</div>
)
}
// Track custom events for experiment metric analysis
import { useLDClient } from 'launchdarkly-react-client-sdk'
function CheckoutButton() {
const client = useLDClient()
function handlePurchase(amount: number) {
// Track conversion event — LD uses this to compute experiment metrics
client?.track('purchase-completed', { amount })
}
return <button onClick={() => handlePurchase(99)}>Buy Now</button>
}
Custom Flag Provider
// For teams that don't need a third-party service:
// Store flags in your own API / database, serve via a single endpoint.
// types
type FlagValue = boolean | string | number | Record<string, unknown>
interface Flags { [key: string]: FlagValue }
// src/flags/FlagProvider.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
const FlagContext = createContext<Flags>({})
export function FlagProvider({ userId, children }: { userId: string; children: ReactNode }) {
const [flags, setFlags] = useState<Flags>({})
useEffect(() => {
// Fetch evaluated flags for this user from your backend
// Backend handles targeting rules, % rollouts, user overrides
fetch(`/api/flags?userId=${userId}`)
.then(r => r.json())
.then(setFlags)
.catch(() => {}) // Always fall back to defaults (empty = all off)
}, [userId])
return <FlagContext.Provider value={flags}>{children}</FlagContext.Provider>
}
// Typed hooks
export function useFlag(key: string, defaultValue: boolean): boolean
export function useFlag(key: string, defaultValue: string): string
export function useFlag(key: string, defaultValue: number): number
export function useFlag<T extends FlagValue>(key: string, defaultValue: T): T {
const flags = useContext(FlagContext)
return (key in flags ? flags[key] : defaultValue) as T
}
// Usage
function PricingPage() {
const showYearlyDiscount = useFlag('yearly-discount-banner', false)
const ctaText = useFlag('pricing-cta-text', 'Get Started')
return (
<div>
{showYearlyDiscount && <YearlyDiscountBanner />}
<button>{ctaText}</button>
</div>
)
}
// Backend flag evaluation (Node.js/Next.js)
// app/api/flags/route.ts
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const userId = searchParams.get('userId') ?? 'anonymous'
const user = await db.user.findUnique({ where: { id: userId } })
const allFlags = await db.featureFlag.findMany({ where: { enabled: true } })
const evaluated: Flags = {}
for (const flag of allFlags) {
evaluated[flag.key] = evaluateFlag(flag, user)
}
return Response.json(evaluated, {
headers: { 'Cache-Control': 'private, max-age=60' }, // Cache 1 min per user
})
}
A/B Testing Pattern
// A/B test: assign users to variants, track the goal metric
// Variant assignment must be STABLE — same user always gets same variant
// hooks/useExperiment.ts
import { useCallback } from 'react'
import { useFlag } from './FlagProvider'
type Variant = 'control' | 'treatment'
export function useExperiment(experimentKey: string) {
const variant = useFlag(experimentKey, 'control') as Variant
// Fire an exposure event so your analytics knows who saw what
// (call this only once, when the experiment UI is rendered)
const trackExposure = useCallback(() => {
analytics.track('experiment_exposure', {
experiment: experimentKey,
variant,
timestamp: Date.now(),
})
}, [experimentKey, variant])
return { variant, isControl: variant === 'control', isTreatment: variant === 'treatment', trackExposure }
}
// Component
import { useEffect } from 'react'
import { useExperiment } from '../hooks/useExperiment'
function HeroSection() {
const { variant, isTreatment, trackExposure } = useExperiment('hero-headline-test')
// Track exposure exactly once when the component mounts
useEffect(() => { trackExposure() }, [trackExposure])
return (
<section>
{isTreatment
? <h1>Ship faster with zero config</h1> // Treatment: benefit-focused
: <h1>The modern developer platform</h1> // Control: current copy
}
<SignupButton experimentVariant={variant} />
</section>
)
}
function SignupButton({ experimentVariant }: { experimentVariant: string }) {
function handleClick() {
// Track goal event — analytics joins this with exposure to compute lift
analytics.track('signup_clicked', { experiment: 'hero-headline-test', variant: experimentVariant })
}
return <button onClick={handleClick}>Start free trial</button>
}
Percentage Rollouts
// Percentage rollout: gradually increase traffic to a new feature
// Use consistent hashing so users don't flip in and out
// Server-side rollout evaluation (stable assignment based on userId hash)
import { createHash } from 'crypto'
function getBucketForUser(userId: string, flagKey: string): number {
// Hash userId + flagKey to get a stable 0–99 bucket
const hash = createHash('md5').update(`${flagKey}:${userId}`).digest('hex')
return parseInt(hash.slice(0, 8), 16) % 100
}
function evaluateRolloutFlag(flag: { rolloutPercent: number; key: string }, userId: string): boolean {
if (flag.rolloutPercent === 0) return false
if (flag.rolloutPercent === 100) return true
return getBucketForUser(userId, flag.key) < flag.rolloutPercent
}
// Rollout schedule — update rolloutPercent in your DB over time:
// Day 1: 1% — internal testing
// Day 2: 5% — canary
// Day 5: 25% — early adopters
// Day 10: 50% — half traffic
// Day 14: 100% — full launch → schedule flag cleanup
// React component using rollout flag
function NewNavigation() {
const useNewNav = useFlag('new-navigation-rollout', false)
return useNewNav ? <NavV2 /> : <NavV1 />
}
// Monitoring during rollout — alert if error rate spikes in treatment bucket
// Track: error_rate, p95_latency, conversion_rate split by flag variant
// Stop the rollout immediately if treatment metrics degrade
Kill Switches
// Kill switch: an ops flag that disables a feature under high load or outage
// Must evaluate to safe=true (feature OFF) when flag service is unreachable
// Pattern: useKillSwitch returns true when the feature should be DISABLED
export function useKillSwitch(key: string): boolean {
// Default: false = feature is ON (switch not tripped)
// If flag service is down, defaultValue=false keeps feature ON
// To make a kill switch "safe off by default", flip the logic at the flag level:
// flag value true = feature ENABLED; false = feature DISABLED (killed)
return !useFlag(key, true) // Default true = feature enabled = kill switch not tripped
}
// Usage
function PaymentProcessor() {
const isKilled = useKillSwitch('payment-processor-enabled')
if (isKilled) {
return (
<div className="maintenance-banner">
<p>Payments are temporarily unavailable. We're working on it. Please try again in a few minutes.</p>
</div>
)
}
return <PaymentForm />
}
// Real-time kill switch with WebSocket push (no polling lag)
// When an incident starts, flip the flag → flag service pushes update to clients
// LaunchDarkly and most providers do this via streaming connection
// With a custom provider, use SSE or WebSocket to push flag changes:
useEffect(() => {
const es = new EventSource('/api/flags/stream')
es.onmessage = (e) => {
const updatedFlags = JSON.parse(e.data)
setFlags(prev => ({ ...prev, ...updatedFlags }))
}
return () => es.close()
}, [])
Flag Cleanup
// Flags are technical debt — clean them up after full rollout or experiment end.
// Uncleaned flags accumulate: 100 flags = 100 code paths to maintain.
// Step 1: Mark stale flags with a comment + expiry date
const CLEANUP_BY = '2026-07-14' // Set a real date when you create the flag
function NewDashboard() {
// TODO: remove flag 'new-dashboard' by 2026-07-14 — fully rolled out
const showNew = useFlag('new-dashboard', false)
return showNew ? <DashboardV2 /> : <DashboardV1 />
}
// Step 2: After full rollout, delete the flag in your provider first
// Step 3: Update code — remove the old branch, keep only the new behavior:
// BEFORE (with flag):
// const show = useFlag('new-dashboard', false)
// return show ? <DashboardV2 /> : <DashboardV1 />
// AFTER (cleaned up):
function Dashboard() {
return <DashboardV2 /> // Old DashboardV1 deleted entirely
}
// For experiments: keep the winner, delete the loser branch
// For kill switches: keep both branches — the kill switch lives permanently
// Tooling: eslint-plugin-feature-flags can warn on flags past their expiry
// Add to your .eslintrc:
// "feature-flags/no-stale-flags": ["warn", { "maxAgeDays": 30 }]
// Flag inventory audit — list all flags in code:
// grep -r 'useFlag\|useBooleanFlagValue' src/ | grep -oP "'[^']+'" | sort | uniq
// Compare against what's live in your flag provider — orphans on either side = cleanup needed
Flag service outage: Always define safe defaults. If your flag provider goes down, your app must degrade gracefully — typically all flags return their defaults. Make kill switches default to
true (feature enabled), not false (feature killed), so an outage doesn't disable your product. Test flag-service-down scenarios in your staging environment.