Feature Flags in React: A Practical Guide
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:
- 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.
- Type augmentation is required for type safety. Once you add the
declare moduleblock, TypeScript will error if you pass an unknown flag name touseFeature.
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:
- Install
@supashiphq/react-sdkand wrap your app inSupaProvider - Define your flags with typed fallbacks
- Use
useFeature/useFeaturesto gate UI branches - Pass user context for targeting
- 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