React Hooks for Feature Flag State
This guide is part of the Frontend Integration & Client-Side Rendering series. It walks through building a FlagProvider context component and a typed useFlag hook using the OpenFeature Web SDK, covering re-render minimization, Suspense loading states, SSR boundary guards, and unit-testing with mock providers.
Problem framing
React applications scatter flag evaluation across components in two failure modes: direct SDK calls duplicated in dozens of files, and a single monolithic context that re-renders the entire tree whenever any flag changes. Both patterns break down at scale — the first because flag keys drift out of sync, the second because unrelated components pay the rendering cost of unrelated flag updates.
The provider-and-hook pattern centralizes SDK lifecycle management in one place while letting individual hooks subscribe to only the flag values they care about, cutting re-renders to the components that actually depend on a changed flag.
What this guide does NOT cover: server-side evaluation strategy (see SSR consistency), how flags reach the browser securely (see secure browser delivery), or SDK bootstrapping before your app mounts (see client SDK initialization).
Prerequisites
@openfeature/web-sdkinstalled (npm i @openfeature/web-sdk)- OpenFeature provider registered before app mount
strictmode enableduseReducer
Core concept and architecture
OpenFeature decouples your application code from any specific flag vendor. Your FlagProvider registers an OpenFeature-compatible provider once, then exposes evaluated flag values through React Context. Individual useFlag hooks subscribe to that context and apply selector memoization so only the components whose flag value changed trigger a re-render.
useFlag call creates an isolated subscription — only the component whose flag value changed re-renders.Step-by-step implementation
Step 1. Install and initialize the OpenFeature Web SDK provider
Install the SDK and your vendor’s OpenFeature provider adapter. Register the provider once, before React mounts. The setProviderAndWait call resolves when the provider signals PROVIDER_READY, so no flag value is consumed before evaluation is available.
// src/flags/setup.ts
import { OpenFeature } from '@openfeature/web-sdk';
import { MyVendorWebProvider } from '@my-vendor/openfeature-web-provider';
export async function initFlags(sdkKey: string): Promise<void> {
const provider = new MyVendorWebProvider({ sdkKey });
await OpenFeature.setProviderAndWait(provider);
}
Call initFlags in your application entry point before rendering:
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { initFlags } from './flags/setup';
import App from './App';
initFlags(import.meta.env.VITE_FLAG_SDK_KEY).then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
});
Pitfall: Rendering the app before setProviderAndWait resolves means every useFlag call returns its default value on first render, then triggers a second render when the provider becomes ready — causing a visible flash. Await initialization before mounting to eliminate this.
Step 2. Build the FlagProvider context component
The provider holds a single Client instance and broadcasts flag state through context. It subscribes to PROVIDER_CONFIGURATION_CHANGED to push updates when flags change remotely. See preventing UI flicker for techniques to avoid the brief unstyled state between provider ready and first paint.
// src/flags/FlagProvider.tsx
import {
createContext,
useContext,
useEffect,
useReducer,
useRef,
type ReactNode,
} from 'react';
import { OpenFeature, ProviderEvents, type Client } from '@openfeature/web-sdk';
type FlagValue = boolean | string | number;
type FlagMap = Record<string, FlagValue>;
interface FlagContextValue {
flags: FlagMap;
ready: boolean;
}
const FlagContext = createContext<FlagContextValue>({ flags: {}, ready: false });
type Action =
| { type: 'READY'; flags: FlagMap }
| { type: 'UPDATE'; flags: FlagMap };
function reducer(
state: FlagContextValue,
action: Action
): FlagContextValue {
switch (action.type) {
case 'READY':
return { flags: action.flags, ready: true };
case 'UPDATE':
return { ...state, flags: { ...state.flags, ...action.flags } };
default:
return state;
}
}
const WATCHED_FLAGS: Array<[string, FlagValue]> = [
['web.dashboard.new-nav', false],
['checkout.payments.express-pay', false],
['web.onboarding.guided-tour', false],
];
function snapshot(client: Client): FlagMap {
return Object.fromEntries(
WATCHED_FLAGS.map(([key, def]) => [
key,
typeof def === 'boolean'
? client.getBooleanValue(key, def as boolean)
: typeof def === 'number'
? client.getNumberValue(key, def as number)
: client.getStringValue(key, def as string),
])
);
}
export function FlagProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { flags: {}, ready: false });
const clientRef = useRef<Client | null>(null);
useEffect(() => {
const client = OpenFeature.getClient('app');
clientRef.current = client;
const onReady = () =>
dispatch({ type: 'READY', flags: snapshot(client) });
const onChange = () =>
dispatch({ type: 'UPDATE', flags: snapshot(client) });
client.addHandler(ProviderEvents.Ready, onReady);
client.addHandler(ProviderEvents.ConfigurationChanged, onChange);
// Provider may already be ready if initFlags resolved before mount
if (OpenFeature.providerMetadata.name !== 'No-op Provider') {
onReady();
}
return () => {
client.removeHandler(ProviderEvents.Ready, onReady);
client.removeHandler(ProviderEvents.ConfigurationChanged, onChange);
};
}, []);
return (
<FlagContext.Provider value={state}>{children}</FlagContext.Provider>
);
}
export function useFlagContext(): FlagContextValue {
return useContext(FlagContext);
}
Pitfall: Calling OpenFeature.getClient() with no name creates a new anonymous client on every render. Pass a stable application-level name (e.g. 'app') to retrieve the same singleton.
Step 3. Write the useFlag hook
useFlag extracts a single value from context. This isolation is the key to selective re-rendering: only components subscribed to web.dashboard.new-nav re-render when that flag changes, not the entire tree.
// src/flags/useFlag.ts
import { useMemo } from 'react';
import { useFlagContext } from './FlagProvider';
export function useFlag<T extends boolean | string | number>(
key: string,
defaultValue: T
): { value: T; ready: boolean } {
const { flags, ready } = useFlagContext();
const value = useMemo(() => {
if (key in flags) return flags[key] as T;
return defaultValue;
}, [flags, key, defaultValue]);
return { value, ready };
}
Usage in a component:
// src/components/NavBar.tsx
import { useFlag } from '../flags/useFlag';
import LegacyNav from './LegacyNav';
import NewNav from './NewNav';
export function NavBar() {
const { value: showNewNav, ready } = useFlag('web.dashboard.new-nav', false);
if (!ready) return <NavSkeleton />;
return showNewNav ? <NewNav /> : <LegacyNav />;
}
Pitfall: Passing an inline object or array as defaultValue creates a new reference on every render and defeats useMemo. Always pass primitives, or lift object defaults to a module-level constant.
Step 4. Minimize re-renders with selector memoization
When WATCHED_FLAGS is large, even a single flag change rebuilds the entire FlagMap and notifies every subscriber. A selector hook prevents this by comparing only the extracted value.
// src/flags/useFlagSelector.ts
import { useRef } from 'react';
import { useFlagContext } from './FlagProvider';
export function useFlagSelector<T>(
selector: (flags: Record<string, boolean | string | number>) => T,
isEqual: (a: T, b: T) => boolean = Object.is
): T {
const { flags } = useFlagContext();
const selected = selector(flags);
const ref = useRef<T>(selected);
if (!isEqual(ref.current, selected)) {
ref.current = selected;
}
return ref.current;
}
For the express-pay flag specifically:
const expressPayEnabled = useFlagSelector(
(f) => f['checkout.payments.express-pay'] === true
);
React’s reconciler bails out of child re-renders when the reference returned by the hook is stable. Only the component holding this hook re-renders when checkout.payments.express-pay changes.
Pitfall: Do not call useCallback around the selector argument inline — useCallback still runs the function. The memoization that prevents re-renders lives in the ref comparison inside the hook, not in how you pass the selector.
Step 5. Handle loading states and Suspense boundaries
Heavy flag-dependent code paths should load lazily. Combine a ready guard with React.Suspense so users see a skeleton while both the feature code and the flag value load in parallel.
// src/features/ExpressCheckout.tsx
import { Suspense, lazy, startTransition, useState, useEffect } from 'react';
import { useFlag } from '../flags/useFlag';
const ExpressPayWidget = lazy(() => import('./ExpressPayWidget'));
export function ExpressCheckout() {
const { value: expressPayEnabled, ready } = useFlag(
'checkout.payments.express-pay',
false
);
const [show, setShow] = useState(false);
useEffect(() => {
if (ready) {
startTransition(() => setShow(expressPayEnabled));
}
}, [ready, expressPayEnabled]);
if (!ready) return <CheckoutSkeleton />;
if (!show) return <StandardCheckout />;
return (
<Suspense fallback={<CheckoutSkeleton />}>
<ExpressPayWidget />
</Suspense>
);
}
startTransition marks the component swap as non-urgent so React can keep the current UI interactive while the lazy bundle loads. The Suspense boundary catches the pending promise from lazy() and renders the skeleton instead of crashing.
Pitfall: Calling startTransition directly in the render body (not inside useEffect) causes an infinite loop because the state setter fires synchronously during render in development. Always trigger transitions from effects or event handlers.
Step 6. Guard the SSR boundary
In Next.js or any SSR framework, FlagProvider runs on the server where OpenFeature.getClient() returns a no-op client. This produces a flag map full of defaults, which differs from the hydrated client-side values and triggers a hydration mismatch. Guard against this with a client-only wrapper.
// src/flags/ClientFlagProvider.tsx
'use client';
import { useState, useEffect, type ReactNode } from 'react';
import { FlagProvider } from './FlagProvider';
export function ClientFlagProvider({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Render children without the flag context on the server and on first
// hydration pass, so the HTML matches. Once mounted, flags layer in.
if (!mounted) return <>{children}</>;
return <FlagProvider>{children}</FlagProvider>;
}
Wrap your root layout with ClientFlagProvider instead of FlagProvider directly. Server-rendered output renders the default-value branch for every flag, which matches what the browser sees before hydration completes. See the Next.js App Router hydration guide for patterns that pre-populate server-resolved flag values to avoid the default-value flash entirely.
Pitfall: Using typeof window !== 'undefined' as the mount guard is unreliable in React 18 concurrent mode because server and client renders can interleave. The useEffect + useState pattern above is the safe idiom because useEffect never runs on the server.
Verification and testing
Render your component with a mock provider that satisfies the FlagContext shape. This avoids network calls and makes flag values deterministic in CI. See the testing React components with mocked flag providers guide for a full fixture library.
// src/flags/__tests__/NavBar.test.tsx
import { render, screen } from '@testing-library/react';
import { FlagContext } from '../FlagProvider';
import { NavBar } from '../../components/NavBar';
function MockFlagProvider({
flags,
children,
}: {
flags: Record<string, boolean | string | number>;
children: React.ReactNode;
}) {
return (
<FlagContext.Provider value={{ flags, ready: true }}>
{children}
</FlagContext.Provider>
);
}
test('renders NewNav when web.dashboard.new-nav is true', () => {
render(
<MockFlagProvider flags={{ 'web.dashboard.new-nav': true }}>
<NavBar />
</MockFlagProvider>
);
expect(screen.getByTestId('new-nav')).toBeInTheDocument();
});
test('renders LegacyNav when web.dashboard.new-nav is false', () => {
render(
<MockFlagProvider flags={{ 'web.dashboard.new-nav': false }}>
<NavBar />
</MockFlagProvider>
);
expect(screen.getByTestId('legacy-nav')).toBeInTheDocument();
});
test('renders skeleton when provider is not ready', () => {
render(
<FlagContext.Provider value={{ flags: {}, ready: false }}>
<NavBar />
</FlagContext.Provider>
);
expect(screen.getByTestId('nav-skeleton')).toBeInTheDocument();
});
Run type-checking alongside unit tests in CI to catch flag key drift:
# .github/workflows/flag-contract-validation.yml
name: Validate Flag Hook Contracts
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx tsc --noEmit --strict
- run: npm test -- --coverage
Troubleshooting & FAQ
Why does the flag return its default value after the provider updates?
The PROVIDER_CONFIGURATION_CHANGED event fired but your component’s useFlag call returned the old value. The most common cause is a stale closure: the handler captured flags at mount time and never re-subscribed. Make sure the snapshot(client) call inside the onChange handler reads from the live client instance, not from a captured copy of flags. If you are memoizing the updateFlags callback with useCallback, verify that its dependency array is empty so it does not re-create on every render — a new function reference each render causes the useEffect to re-run and re-attach handlers in a tight loop.
How do I prevent the whole tree from re-rendering on every flag change?
Split context into two separate contexts: one for the flag map (FlagsContext) and one for the ready boolean (FlagReadyContext). Components that only need to know whether the provider is ready do not subscribe to the flag map and will not re-render when flags change. Use useFlagSelector (Step 4) inside leaf components to extract only the value they need, and wrap expensive subtrees in React.memo so React skips them when their props have not changed.
Can I use this pattern in a Next.js App Router project?
Yes. Use ClientFlagProvider (Step 6) in your root layout.tsx with the 'use client' directive. Server Components that need flag values at render time should read from a server-side evaluation source rather than the client SDK — see the Next.js App Router hydration guide for a pattern that passes server-resolved flags as props into the client provider, eliminating the default-value flash without breaking SSR.
Performance and scale
The WATCHED_FLAGS array in FlagProvider determines how many SDK evaluations run on every PROVIDER_CONFIGURATION_CHANGED event. For applications with more than 20 flags, replace the snapshot approach with per-flag event listeners: subscribe each useFlag call to the specific key’s change event using OpenFeature’s addHandler overload that accepts a flag key filter. This reduces snapshot cost from O(n flags) to O(1) per changed flag.
Memory overhead is proportional to the number of mounted components with active useFlag subscriptions, not the total number of flags. Components that unmount clean up their subscriptions in useEffect return functions, so there is no accumulation over navigation events in single-page applications.
For server-side rendering at scale, flag evaluation belongs on the server before the response is sent — not in the client SDK. The client provider should hydrate from pre-evaluated values rather than re-evaluating after mount. This eliminates the round-trip latency and the default-value flash. See SSR consistency for the full pattern.