How to Implement Feature Flags in React Apps
Implementing feature flags in React involves three layers: fetch flags from a remote config service, cache them in memory or context, and expose them to components via a hook. This article walks through building a production-ready system that handles stale flags, network errors, and real-time updates.
I built my first custom flag system in 2019 for a React SPA serving 2M users. We fetched flags on app startup, cached in localStorage, and polled every 60 seconds. It worked, but the polling was overkill—most flags never changed. Today, I recommend a simpler approach: fetch once on startup, cache aggressively, and provide a manual refresh for ops toggles. Let's build it step by step.
Architecture Overview: Three Layers
A feature flag system in React has three layers: transport (fetch flags from a remote service), state (cache flags in React context), and consumption (use flags in components via hooks).
┌─────────────────────────────────────────────────┐
│ React App (Components) │
│ ├─ <Checkout /> │
│ │ └─ const isPaidFeature = useFeatureFlag() │
│ ├─ <Settings /> │
│ │ └─ const isDarkMode = useFeatureFlag() │
│ └─ ... │
└──────────────┬──────────────────────────────────┘
│ hook: useFeatureFlag('name')
┌──────────────▼──────────────────────────────────┐
│ FlagContext (state + dispatch) │
│ ├─ flags: { 'payment_v2': true, ... } │
│ ├─ loaded: boolean │
│ └─ refetch: () => Promise<void> │
└──────────────┬──────────────────────────────────┘
│ dispatch actions on fetch/update
┌──────────────▼──────────────────────────────────┐
│ Flag Transport (API calls) │
│ ├─ GET /api/flags?userId=123 │
│ ├─ retry on network error │
│ └─ cache with ETag / If-None-Match │
└──────────────┬──────────────────────────────────┘
│ HTTP
┌──────────────▼──────────────────────────────────┐
│ Config Service (e.g., /api/flags endpoint) │
│ ├─ Evaluates rules per user │
│ ├─ Returns { 'flag_name': value, ... } │
│ └─ Returns Cache-Control headers for caching │
└──────────────────────────────────────────────────┘
Layer 1: Transport (Fetch)
The transport layer is responsible for fetching flags from a remote API. Keep it simple: one async function that handles retries and caching headers.
// flagsApi.js - Transport layer for fetching flags
let cachedFlags = null;
let cachedETag = null;
let lastFetch = 0;
const CACHE_TTL = 60 * 1000; // 1 minute
export async function fetchUserFlags(userId, opts = {}) {
const { force = false } = opts;
const now = Date.now();
// Return cached flags if fresh (unless forced)
if (
!force &&
cachedFlags &&
now - lastFetch < CACHE_TTL &&
cachedETag
) {
return cachedFlags;
}
try {
const headers = {};
if (cachedETag && !force) {
headers['If-None-Match'] = cachedETag;
}
const response = await fetch(
`/api/flags?userId=${encodeURIComponent(userId)}`,
{ headers, credentials: 'include' }
);
if (response.status === 304) {
// Not modified; server says cached version is still valid
lastFetch = now;
return cachedFlags;
}
if (!response.ok) {
throw new Error(
`Flag fetch failed: ${response.status} ${response.statusText}`
);
}
const flags = await response.json();
cachedFlags = flags;
cachedETag = response.headers.get('ETag') || null;
lastFetch = now;
return flags;
} catch (error) {
// If fetch fails, return cached flags if available
if (cachedFlags) {
console.warn('Flag fetch error; using cached flags:', error);
return cachedFlags;
}
// Otherwise, throw so caller can handle offline
throw error;
}
}
This function handles three cases:
- Cache hit: If flags are fresh (< 60 seconds old), return the cached copy immediately.
- Conditional fetch: Use HTTP caching headers (
ETag,If-None-Match) to avoid re-downloading unchanged data. - Fallback: If the network is down but we have a cache, return stale flags rather than fail.
Layer 2: State Management with Context
The state layer holds flags in React context and provides a hook for consumption.
// FlagContext.js - State layer
import React, { createContext, useReducer, useEffect, useCallback } from 'react';
import { fetchUserFlags } from './flagsApi';
const FlagContext = createContext(null);
// Reducer to handle flag updates
function flagReducer(state, action) {
switch (action.type) {
case 'LOADING':
return { ...state, loading: true, error: null };
case 'LOADED':
return {
...state,
flags: action.payload,
loading: false,
error: null,
};
case 'ERROR':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
export function FlagProvider({ userId, children }) {
const [state, dispatch] = useReducer(flagReducer, {
flags: {},
loading: true,
error: null,
});
// Fetch flags on mount
useEffect(() => {
dispatch({ type: 'LOADING' });
fetchUserFlags(userId)
.then((flags) => {
dispatch({ type: 'LOADED', payload: flags });
})
.catch((error) => {
dispatch({ type: 'ERROR', payload: error.message });
});
}, [userId]);
// Callback for manual refresh (e.g., after user role change)
const refetch = useCallback(async () => {
dispatch({ type: 'LOADING' });
try {
const flags = await fetchUserFlags(userId, { force: true });
dispatch({ type: 'LOADED', payload: flags });
} catch (error) {
dispatch({ type: 'ERROR', payload: error.message });
}
}, [userId]);
return (
<FlagContext.Provider value={{ ...state, refetch }}>
{children}
</FlagContext.Provider>
);
}
export function useFeatureFlag(flagName, defaultValue = false) {
const context = React.useContext(FlagContext);
if (!context) {
throw new Error('useFeatureFlag must be used within a FlagProvider');
}
return context.flags[flagName] ?? defaultValue;
}
// Hook to check if flags are still loading
export function useFlagState() {
const context = React.useContext(FlagContext);
if (!context) {
throw new Error('useFlagState must be used within a FlagProvider');
}
return context;
}
Layer 3: Consumption in Components
Components consume flags via the useFeatureFlag hook. Keep it simple: if the flag is true, render the new component; otherwise, render the legacy one.
// Checkout.jsx - Consumption layer
import { useFeatureFlag } from './FlagContext';
export function Checkout() {
const usePaymentV2 = useFeatureFlag('payment_v2', false);
return (
<div className="checkout-container">
<h1>Checkout</h1>
{usePaymentV2 ? (
<PaymentV2 />
) : (
<PaymentLegacy />
)}
</div>
);
}
For multi-variant flags (not just on/off), return the string value:
// Dashboard.jsx - Multi-variant flag
export function Dashboard() {
const layoutVariant = useFeatureFlag('dashboard_layout', 'classic');
return (
<div>
{layoutVariant === 'modern' && <DashboardModern />}
{layoutVariant === 'classic' && <DashboardClassic />}
{layoutVariant === 'minimal' && <DashboardMinimal />}
</div>
);
}
Wiring It Up at App Root
At your app's root (usually App.jsx or main.jsx), wrap your entire component tree with the FlagProvider. Pass the current user ID so the provider can fetch personalized flags.
// App.jsx
import { FlagProvider } from './FlagContext';
import { useAuth } from './auth';
export function App() {
const { user } = useAuth();
if (!user) {
return <LoginPage />;
}
return (
<FlagProvider userId={user.id}>
<Router>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/checkout" element={<Checkout />} />
</Routes>
</Layout>
</Router>
</FlagProvider>
);
}
Handling Stale and Offline Scenarios
Flags are stale if the network is slow or the user was offline when the app launched. Your system should handle both gracefully.
- Stale flags: Return cached flags even if they're old. Users won't see broken behavior, just potentially outdated experiments.
- Offline: If offline and no cache exists, default to
falsefor all flags (disable new features). This is the safest fallback.
The fetchUserFlags function above handles offline gracefully: if the fetch fails and a cache exists, it returns the cache. If the fetch fails and no cache exists, it throws, and the FlagProvider catches the error and sets state.error. Components can check useFlagState().error and fall back to defaults.
Key Takeaways
- Feature flags in React are fetched from a remote config service at app startup and cached in React context.
- Use HTTP caching headers (ETag) to avoid re-downloading unchanged flag data.
- Provide a
useFeatureFlaghook so components can access flags without prop drilling. - Implement a manual
refetch()function for ops-toggle updates or user permission changes. - Gracefully handle offline and stale scenarios: return cached flags rather than fail.
- Keep the transport layer separate from the state layer so you can swap backends (e.g., LaunchDarkly) without rewriting components.
Frequently Asked Questions
How do I handle flags that change while the user is viewing the page?
If you need real-time flag updates (e.g., ops turning off a broken feature), implement either polling or WebSocket subscriptions. Polling is simpler: call refetch() every 30–60 seconds via useInterval. WebSockets are more responsive but add complexity. For most apps, polling every 60 seconds is sufficient.
Should I use localStorage to persist flags?
Yes, if offline functionality is important. Add two lines to flagsApi.js: serialize cachedFlags to localStorage after fetch, and deserialize on app startup if the fetch fails. This ensures users offline can still use your app with the last-known flags.
What if the /api/flags endpoint is slow?
Measure it. If it's slower than 500 ms, consider loading flags in parallel with other critical app data (user profile, navigation). Use Promise.all([fetchUser(), fetchFlags()]) at your app root. You can show a skeleton UI while both load. Alternatively, cache flags in a CDN or edge cache (e.g., Cloudflare) so most requests hit the cache, not your origin.
Can I test flags locally without a backend?
Yes. Wrap fetchUserFlags in a function that returns mock flags if process.env.NODE_ENV === 'development'. Or use a library like msw (Mock Service Worker) to intercept the fetch and return mock data in tests.