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.

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.