Feature Flags in React: A Practical Guide

Supa DeveloperSupa Developer
··5 min read

Feature flags let you ship code to production and decide later - or gradually - who actually sees it. In React that means wrapping UI branches in a condition driven by a flag value instead of a hardcoded boolean.

This guide walks through everything from a simple toggle to multi-flag batching, user targeting, and writing tests that don't depend on a real API.

Installation

pnpm add @supashiphq/react-sdk

Install @supashiphq/react-sdk from npm. See the full React SDK reference for all provider options, hook signatures, and TypeScript utilities.

Setting up the provider

SupaProvider fetches your flags once and makes them available anywhere in the tree via hooks. Wrap it as high as possible - usually your app root.

// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import {
  SupaProvider,
  FeaturesWithFallbacks,
  InferFeatures,
} from '@supashiphq/react-sdk'
import App from './App'

// Define every flag your app uses along with its fallback value.
// The fallback is shown while flags are loading and if the API is unreachable.
const FEATURE_FLAGS = {
  'new-header': false,
  'show-announcement-bar': false,
  'theme-config': { mode: 'light' as 'light' | 'dark', showLogo: true },
  'beta-features': [] as string[],
} satisfies FeaturesWithFallbacks

// Type augmentation  -  makes useFeature fully type-safe throughout your app
declare module '@supashiphq/react-sdk' {
  interface Features extends InferFeatures<typeof FEATURE_FLAGS> {}
}

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <SupaProvider
      config={{
        apiKey: import.meta.env.VITE_SUPASHIP_API_KEY,
        environment: import.meta.env.MODE,
        features: FEATURE_FLAGS,
      }}>
      <App />
    </SupaProvider>
  </StrictMode>,
)

Two things to notice:

  1. Fallback values matter. They determine what the user sees while flags are loading - or if your network is slow. Use safe defaults that preserve existing behaviour.
  2. Type augmentation is required for type safety. Once you add the declare module block, TypeScript will error if you pass an unknown flag name to useFeature.

Toggling a single feature

import { useFeature } from '@supashiphq/react-sdk'

export function Header() {
  const { feature: newHeader, isLoading } = useFeature('new-header')

  if (isLoading) return <HeaderSkeleton />

  return newHeader ? <NewHeader /> : <OldHeader />
}

useFeature returns { feature, isLoading, error }. Always handle isLoading - rendering nothing while flags load causes layout shift; rendering the fallback content avoids it.

Batching multiple flags

Every useFeature call is a separate hook invocation. If you need several flags in one component, use useFeatures instead - it makes a single API call for all of them.

import { useFeatures } from '@supashiphq/react-sdk'

export function AppShell() {
  const { features, isLoading } = useFeatures([
    'new-header',
    'show-announcement-bar',
    'beta-features',
  ])

  if (isLoading) return <AppSkeleton />

  return (
    <>
      {features['show-announcement-bar'] && <AnnouncementBar />}
      {features['new-header'] ? <NewHeader /> : <LegacyHeader />}
      <main>
        {features['beta-features'].includes('ai-search') && <AISearchBar />}
      </main>
    </>
  )
}

Notice the third flag - beta-features - is an array. Supaship supports non-boolean flag values (objects, arrays, strings, numbers) so you can carry configuration alongside the toggle.

Object-valued flags for configuration

Boolean flags answer "is this on?" but sometimes you want the flag to also carry how something should behave. Define the flag as an object in your FEATURE_FLAGS map:

const FEATURE_FLAGS = {
  'theme-config': { mode: 'light' as 'light' | 'dark', showLogo: true },
} satisfies FeaturesWithFallbacks

Then consume it:

import { useFeature } from '@supashiphq/react-sdk'

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const { feature: theme } = useFeature('theme-config')

  // theme is typed as { mode: 'light' | 'dark', showLogo: boolean } | null
  const resolvedTheme = theme ?? { mode: 'light', showLogo: true }

  return (
    <div data-theme={resolvedTheme.mode}>
      {resolvedTheme.showLogo && <Logo />}
      {children}
    </div>
  )
}

This replaces a whole family of individual flags (dark-mode-enabled, show-logo-in-dark-mode, etc.) with a single structured value you can update atomically from the Supaship dashboard.

User targeting with context

Flags become much more powerful once you attach user context - Supaship uses it to evaluate targeting rules like "only show this to users on the Pro plan" or "roll out to 10% of users".

Pass context to SupaProvider initially, then update it reactively when the user logs in or their state changes:

// src/providers/FeatureFlagProvider.tsx
'use client'
import { useEffect } from 'react'
import {
  SupaProvider,
  useFeatureContext,
  FeaturesWithFallbacks,
} from '@supashiphq/react-sdk'
import { useAuth } from '../hooks/useAuth'

const FEATURE_FLAGS = { 'new-dashboard': false } satisfies FeaturesWithFallbacks

// Inner component so it can call useFeatureContext
function UserContextSync() {
  const { updateContext } = useFeatureContext()
  const { user } = useAuth()

  useEffect(() => {
    if (user) {
      updateContext({
        userId: user.id,
        email: user.email,
        plan: user.plan, // used for plan-based targeting
        segment: user.segment, // used for segment-based targeting
      })
    }
  }, [user, updateContext])

  return null
}

export function FeatureFlagProvider({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <SupaProvider
      config={{
        apiKey: import.meta.env.VITE_SUPASHIP_API_KEY,
        environment: import.meta.env.MODE,
        features: FEATURE_FLAGS,
      }}>
      <UserContextSync />
      {children}
    </SupaProvider>
  )
}

Once updateContext is called, the SDK re-evaluates all active flags against the new context. Components using useFeature re-render automatically with the correct value for that user.

Building a reusable <Feature> guard component

If you prefer a declarative approach, wrap useFeature in a component that handles loading and gating for you:

// src/components/Feature.tsx
import { useFeature } from '@supashiphq/react-sdk'

interface FeatureProps {
  flag: string
  fallback?: React.ReactNode
  children: React.ReactNode
}

export function Feature({ flag, fallback = null, children }: FeatureProps) {
  const { feature: isEnabled, isLoading } = useFeature(flag as any)

  if (isLoading) return null
  return isEnabled ? <>{children}</> : <>{fallback}</>
}

Usage is clean and readable:

<Feature flag="show-announcement-bar">
  <AnnouncementBar />
</Feature>

<Feature flag="new-header" fallback={<LegacyHeader />}>
  <NewHeader />
</Feature>

Handling the loading state well

A poorly handled loading state is often worse than the flag not existing at all. Three patterns, in order of preference:

1. Skeleton / placeholder - best for content that has a defined shape:

const { feature, isLoading } = useFeature('new-dashboard')
if (isLoading) return <DashboardSkeleton />
return feature ? <NewDashboard /> : <LegacyDashboard />

2. Render the default branch - keeps the UI stable by showing what the user would see if the flag were off:

const { feature, isLoading } = useFeature('new-header')
// isLoading === false once flags resolve; until then feature === false (the fallback)
return feature ? <NewHeader /> : <LegacyHeader />

Because the fallback value in FEATURE_FLAGS is false, feature starts as false during loading, so the legacy header is shown immediately. No layout shift, no blank space.

3. Conditional fetch - for flags that depend on user data being ready first:

const { user, isLoading: userLoading } = useUser()

const { features, isLoading: flagsLoading } = useFeatures(
  ['user-specific-feature'],
  {
    context: { userId: user?.id },
    shouldFetch: !userLoading && !!user, // don't fetch until user is ready
  },
)

Reacting to page-level context changes

Sometimes a flag should be re-evaluated when the user navigates - for example, a flag that targets users on a specific page. Use updateContext inside a router effect:

import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useFeatureContext } from '@supashiphq/react-sdk'

export function RouteContextSync() {
  const { updateContext } = useFeatureContext()
  const location = useLocation()

  useEffect(() => {
    updateContext({ currentPage: location.pathname })
  }, [location.pathname, updateContext])

  return null
}

Add <RouteContextSync /> near the top of your app (inside SupaProvider) and the SDK will re-evaluate flags whenever the route changes.

Testing without a real API

Pass hardcoded feature values to a SupaProvider in your test setup. Flags resolve instantly - no network, no mocking, no async waiting.

// test-utils/providers.tsx
import { SupaProvider, FeaturesWithFallbacks } from '@supashiphq/react-sdk'

export function TestProviders({
  children,
  features = {},
}: {
  children: React.ReactNode
  features?: FeaturesWithFallbacks
}) {
  return (
    <SupaProvider
      config={{
        apiKey: 'test-key',
        environment: 'test',
        features: { ...features } satisfies FeaturesWithFallbacks,
      }}>
      {children}
    </SupaProvider>
  )
}
// Header.test.tsx
import { render, screen } from '@testing-library/react'
import { TestProviders } from '../test-utils/providers'
import { Header } from './Header'

describe('Header', () => {
  it('renders the new header when the flag is enabled', () => {
    render(
      <TestProviders features={{ 'new-header': true }}>
        <Header />
      </TestProviders>,
    )
    expect(screen.getByTestId('new-header')).toBeInTheDocument()
  })

  it('renders the legacy header when the flag is disabled', () => {
    render(
      <TestProviders features={{ 'new-header': false }}>
        <Header />
      </TestProviders>,
    )
    expect(screen.getByTestId('legacy-header')).toBeInTheDocument()
  })
})

Each test explicitly declares the flag state it cares about, making failures easy to diagnose.

Common pitfalls

Calling useFeature inside conditions or loops

React hooks must be called unconditionally. This breaks:

// ❌ BAD
if (user.isPro) {
  const { feature } = useFeature('pro-feature') // Rules of Hooks violation
}

Always call the hook at the top of the component and gate the render logic below it:

// ✅ GOOD
const { feature: proFeature } = useFeature('pro-feature')
if (!user.isPro) return null
return proFeature ? <ProFeature /> : <StandardFeature />

Forgetting to clean up stale flags

Flags that have been fully rolled out (or rolled back) should be removed from both the Supaship dashboard and your codebase. A flag that's been true for six months is just dead code wrapped in an if statement. Set a calendar reminder when you create a flag to clean it up once the rollout is complete.

Using flags for permanent configuration

Feature flags are for temporary decisions - rollouts, experiments, kill switches. If a value will never change (your app's primary colour, your API base URL), use an environment variable instead.

The development toolbar

SupaProvider ships with a built-in toolbar for overriding flag values locally without touching the dashboard. It shows automatically in development:

<SupaProvider
  config={{ ... }}
  toolbar={{
    show: 'auto',          // 'auto' | 'always' | 'never'
    position: 'bottom-right',
  }}>
  <App />
</SupaProvider>

'auto' means the toolbar appears only when NODE_ENV === 'development', so it never leaks to production. Use it to quickly test both branches of a flag without touching your code.

What to do next

You now have everything you need to add feature flags to a React app:

  1. Install @supashiphq/react-sdk and wrap your app in SupaProvider
  2. Define your flags with typed fallbacks
  3. Use useFeature / useFeatures to gate UI branches
  4. Pass user context for targeting
  5. Write tests with TestProviders

Ready to try it? Create a free Supaship account and have your first flag live in minutes. Free forever up to 1M events/month. Pro plan is $30/month with unlimited projects and team members. See the full React SDK reference and the dev toolbar docs.


Related framework guides: Feature Flags in Next.js · Feature Flags in Vue · Feature Flags in Node.js

Comparing platforms? Supaship vs LaunchDarkly · Supaship vs Statsig · Supaship vs ConfigCat · Best Feature Flag Platforms 2026


Feedback

Got thoughts on this?

We're constantly learning how developers actually use these tools. Ideas, use cases, integration requests — every bit of feedback makes the platform better for everyone.

Thanks for being part of the journey — Supaship