Feature flags are no longer just boolean switches hidden in a config file. In 2026, they are a core delivery system for React teams that want to ship safely, run controlled experiments, and decouple deploys from releases.
In this guide, you will build a practical, typed feature flag system for a React app using:
- React + TypeScript
- An Edge Config style JSON endpoint
- Local caching and fail-safe defaults
- Role and percentage targeting
- Gradual rollout workflow
By the end, you will have production-ready patterns you can adapt to your own frontend platform.
Why frontend teams need typed flags now
Many teams still treat flags as untyped strings. That usually causes three problems:
- Typos in flag names break behavior silently.
- Different apps interpret the same flag differently.
- Old flags never get cleaned up because ownership is unclear.
A typed contract solves this. Your React app gets compile-time safety, predictable defaults, and easier cleanup after rollout.
Architecture overview
We will use a simple architecture that works well for blogs, SaaS dashboards, and internal tools:
- Flags source: a JSON document served from edge storage/CDN.
- React client: fetches flags on startup, validates shape, caches with TTL.
- Evaluation layer: computes enabled/disabled based on user context.
- UI hooks:
useFlag()and<FeatureGate />.
Step 1: Define a strict flag schema
Start by defining the flags your app supports. Keep names explicit and domain-focused.
// flags/schema.ts
export type FlagKey =
| 'newCheckoutFlow'
| 'aiSummaryCard'
| 'enableTeamBilling'
| 'navV2';
export type RolloutRule = {
percentage?: number; // 0..100
roles?: string[];
countries?: string[];
};
export type FlagDefinition = {
enabled: boolean;
rule?: RolloutRule;
owner: string;
expiresOn?: string; // ISO date for cleanup discipline
};
export type FlagSet = Record<FlagKey, FlagDefinition>;
export const defaultFlags: FlagSet = {
newCheckoutFlow: { enabled: false, owner: 'payments-team' },
aiSummaryCard: { enabled: false, owner: 'content-team' },
enableTeamBilling: { enabled: false, owner: 'billing-team' },
navV2: { enabled: false, owner: 'frontend-platform' }
};
This makes accidental flag usage impossible unless it is declared in the shared type.
Step 2: Build a safe edge-config fetcher
Next, fetch your remote config and merge it with defaults. If the network fails, your app still behaves safely.
// flags/client.ts
import { defaultFlags, FlagSet } from './schema';
const FLAGS_URL = 'https://edge.example.com/config/flags.json';
const CACHE_KEY = 'app_flags_cache_v1';
const TTL_MS = 60_000;
type CachedFlags = { ts: number; data: FlagSet };
function isFlagSet(value: unknown): value is FlagSet {
if (!value || typeof value !== 'object') return false;
return Object.keys(defaultFlags).every((k) => k in (value as object));
}
export async function loadFlags(): Promise<FlagSet> {
const now = Date.now();
const cachedRaw = localStorage.getItem(CACHE_KEY);
if (cachedRaw) {
try {
const cached: CachedFlags = JSON.parse(cachedRaw);
if (now - cached.ts < TTL_MS) return cached.data;
} catch {
// ignore bad cache
}
}
try {
const res = await fetch(FLAGS_URL, { cache: 'no-store' });
if (!res.ok) throw new Error(`Failed to fetch flags: ${res.status}`);
const remote = await res.json();
if (!isFlagSet(remote)) throw new Error('Invalid flag schema');
const merged = { ...defaultFlags, ...remote };
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: now, data: merged }));
return merged;
} catch {
return defaultFlags;
}
}
Notice the fallback behavior: never block app rendering because a config endpoint is down.
Step 3: Add deterministic rollout targeting
For percentage rollouts, you need deterministic bucketing. A user should stay in the same bucket between sessions.
// flags/evaluate.ts
import { FlagDefinition } from './schema';
export type UserContext = {
userId: string;
role?: string;
country?: string;
};
function hashToPercent(input: string): number {
let h = 2166136261;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
}
return Math.abs(h % 100);
}
export function isFlagEnabled(
key: string,
flag: FlagDefinition,
user: UserContext
): boolean {
if (!flag.enabled) return false;
if (!flag.rule) return true;
const { percentage, roles, countries } = flag.rule;
if (roles?.length && (!user.role || !roles.includes(user.role))) return false;
if (countries?.length && (!user.country || !countries.includes(user.country))) return false;
if (typeof percentage === 'number') {
const bucket = hashToPercent(`${key}:${user.userId}`);
return bucket < percentage;
}
return true;
}
This enables progressive delivery like 5%, then 20%, then 50%, then 100% without random behavior.
Step 4: React provider + hook
Create a provider that loads flags once and exposes a hook for components.
// flags/provider.tsx
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { defaultFlags, FlagKey, FlagSet } from './schema';
import { loadFlags } from './client';
import { isFlagEnabled, UserContext } from './evaluate';
type FlagsContextType = {
rawFlags: FlagSet;
enabled: (key: FlagKey, user: UserContext) => boolean;
};
const FlagsContext = createContext<FlagsContextType>({
rawFlags: defaultFlags,
enabled: () => false
});
export function FlagsProvider({ children }: { children: React.ReactNode }) {
const [rawFlags, setRawFlags] = useState<FlagSet>(defaultFlags);
useEffect(() => {
loadFlags().then(setRawFlags).catch(() => setRawFlags(defaultFlags));
}, []);
const value = useMemo(() => ({
rawFlags,
enabled: (key: FlagKey, user: UserContext) => isFlagEnabled(key, rawFlags[key], user)
}), [rawFlags]);
return <FlagsContext.Provider value={value}>{children}</FlagsContext.Provider>;
}
export function useFlag(key: FlagKey, user: UserContext) {
const ctx = useContext(FlagsContext);
return ctx.enabled(key, user);
}
Step 5: Gate UI and routes cleanly
Now use the hook in real components.
// components/BillingEntry.tsx
import { useFlag } from '../flags/provider';
export function BillingEntry({ user }: { user: { id: string; role: string; country: string } }) {
const showTeamBilling = useFlag('enableTeamBilling', {
userId: user.id,
role: user.role,
country: user.country
});
if (!showTeamBilling) return null;
return <a href="/billing/team">Team Billing</a>;
}
Keep flag checks close to entry points. Avoid scattering conditions across deep children when possible.
Production rollout playbook
1) Start internal only
Set roles: ["admin", "staff"] first. Validate UX and telemetry before exposing to customers.
2) Add low percentage
Move to percentage: 5, then 20. Monitor key metrics, error rates, and support tickets.
3) Add a kill switch
Every risky flag should be disabled instantly without redeploy. That is the operational value of this system.
4) Expire old flags
Use the expiresOn field and enforce cleanup in CI. Stale flags create long-term complexity debt.
Common mistakes to avoid
- Flag explosion: too many flags with no owner or expiry.
- Client-only security: never use frontend flags to enforce permissions.
- Inconsistent context: different user IDs across services break deterministic rollouts.
- No observability: track which flag variants users saw during incidents.
Final checklist for your React app
- Typed flag keys shared across app packages.
- Safe defaults and offline fallback.
- Deterministic percentage bucketing.
- Role and region targeting support.
- Owner + expiry metadata for cleanup.
- Kill switch tested in staging and production.
If you implement this pattern, your team can ship React features faster with lower release risk. You get confidence to iterate in small steps instead of betting everything on one big deployment.
That is exactly what modern frontend engineering should optimize for in 2026: velocity with control.

Leave a Reply