Back to Journal
SaaS Engineering

How to Build Feature Flag Architecture Using React

Step-by-step tutorial for building Feature Flag Architecture with React, from project setup through deployment.

Muneer Puthiya Purayil 24 min read

This tutorial covers building a feature flag system in React from scratch, including a flag evaluation engine, React context provider, hooks for conditional rendering, and a developer tools panel for debugging flag states.

Project Setup

bash
1npx create-react-app feature-flags --template typescript
2cd feature-flags
3npm install zod react-router-dom
4 

Flag Evaluation Engine

The evaluation engine runs in the browser, operating on flag configurations fetched from your API:

typescript
1// src/lib/flags/evaluator.ts
2interface Condition {
3 attribute: string;
4 operator: "eq" | "neq" | "in" | "contains" | "starts_with";
5 values: string[];
6}
7 
8interface Rule {
9 conditions: Condition[];
10 variant: string;
11 priority: number;
12}
13 
14export interface FlagConfig {
15 key: string;
16 enabled: boolean;
17 percentage: number;
18 rules: Rule[];
19 defaultVariant: string;
20}
21 
22export interface EvalContext {
23 userId: string;
24 email?: string;
25 plan?: string;
26 country?: string;
27 properties?: Record<string, string>;
28}
29 
30export interface EvalResult {
31 enabled: boolean;
32 variant: string;
33 reason: string;
34}
35 
36export class FlagEvaluator {
37 private flags = new Map<string, FlagConfig>();
38 
39 update(configs: FlagConfig[]): void {
40 const newFlags = new Map<string, FlagConfig>();
41 for (const config of configs) {
42 newFlags.set(config.key, config);
43 }
44 this.flags = newFlags;
45 }
46 
47 evaluate(flagKey: string, context: EvalContext): EvalResult {
48 const flag = this.flags.get(flagKey);
49 if (!flag || !flag.enabled) {
50 return { enabled: false, variant: "", reason: "disabled" };
51 }
52 
53 const sortedRules = [...flag.rules].sort((a, b) => b.priority - a.priority);
54 for (const rule of sortedRules) {
55 if (rule.conditions.every(c => this.matchCondition(c, context))) {
56 return { enabled: true, variant: rule.variant, reason: "rule_match" };
57 }
58 }
59 
60 if (flag.percentage > 0 && flag.percentage < 100) {
61 const bucket = this.hashBucket(flagKey, context.userId);
62 if (bucket < flag.percentage) {
63 return { enabled: true, variant: flag.defaultVariant, reason: "percentage" };
64 }
65 return { enabled: false, variant: "", reason: "percentage_excluded" };
66 }
67 
68 return { enabled: true, variant: flag.defaultVariant, reason: "default" };
69 }
70 
71 isEnabled(flagKey: string, context: EvalContext): boolean {
72 return this.evaluate(flagKey, context).enabled;
73 }
74 
75 getAllFlags(): Map<string, FlagConfig> {
76 return this.flags;
77 }
78 
79 private async hashBucket(flagKey: string, userId: string): Promise<number> {
80 // Browser-compatible hash using SubtleCrypto
81 const encoder = new TextEncoder();
82 const data = encoder.encode(`${flagKey}:${userId}`);
83 const hashBuffer = await crypto.subtle.digest("SHA-256", data);
84 const view = new DataView(hashBuffer);
85 return (view.getUint32(0) / 0xffffffff) * 100;
86 }
87 
88 // Synchronous fallback using simple hash
89 private hashBucketSync(flagKey: string, userId: string): number {
90 let hash = 0;
91 const str = `${flagKey}:${userId}`;
92 for (let i = 0; i < str.length; i++) {
93 const char = str.charCodeAt(i);
94 hash = ((hash << 5) - hash) + char;
95 hash = hash & hash;
96 }
97 return Math.abs(hash % 100);
98 }
99 
100 private matchCondition(cond: Condition, ctx: EvalContext): boolean {
101 const value = this.getAttribute(cond.attribute, ctx);
102 switch (cond.operator) {
103 case "eq": return cond.values[0] === value;
104 case "neq": return cond.values[0] !== value;
105 case "in": return cond.values.includes(value);
106 case "contains": return value.includes(cond.values[0]);
107 case "starts_with": return value.startsWith(cond.values[0]);
108 default: return false;
109 }
110 }
111 
112 private getAttribute(attr: string, ctx: EvalContext): string {
113 const map: Record<string, string | undefined> = {
114 user_id: ctx.userId, email: ctx.email, plan: ctx.plan, country: ctx.country,
115 };
116 return map[attr] ?? ctx.properties?.[attr] ?? "";
117 }
118}
119 

React Context and Provider

typescript
1// src/lib/flags/FlagProvider.tsx
2import React, { createContext, useContext, useEffect, useMemo, useState, useCallback, ReactNode } from "react";
3import { FlagEvaluator, FlagConfig, EvalContext, EvalResult } from "./evaluator";
4 
5interface FlagContextValue {
6 isEnabled: (flagKey: string) => boolean;
7 getVariant: (flagKey: string) => string;
8 getResult: (flagKey: string) => EvalResult;
9 isLoading: boolean;
10 allFlags: Record<string, EvalResult>;
11}
12 
13const FlagContext = createContext<FlagContextValue | null>(null);
14const evaluator = new FlagEvaluator();
15 
16export function FlagProvider({
17 children,
18 context,
19 apiUrl,
20 refreshInterval = 30000,
21}: {
22 children: ReactNode;
23 context: EvalContext;
24 apiUrl: string;
25 refreshInterval?: number;
26}) {
27 const [isLoading, setIsLoading] = useState(true);
28 const [version, setVersion] = useState(0);
29 
30 const fetchFlags = useCallback(async () => {
31 try {
32 const response = await fetch(`${apiUrl}/api/flags`);
33 const configs: FlagConfig[] = await response.json();
34 evaluator.update(configs);
35 setVersion(v => v + 1);
36 } catch (error) {
37 console.error("Failed to fetch flags:", error);
38 } finally {
39 setIsLoading(false);
40 }
41 }, [apiUrl]);
42 
43 useEffect(() => {
44 fetchFlags();
45 const interval = setInterval(fetchFlags, refreshInterval);
46 return () => clearInterval(interval);
47 }, [fetchFlags, refreshInterval]);
48 
49 const value = useMemo(() => {
50 const cache: Record<string, EvalResult> = {};
51 
52 const getResult = (flagKey: string): EvalResult => {
53 if (!cache[flagKey]) {
54 cache[flagKey] = evaluator.evaluate(flagKey, context);
55 }
56 return cache[flagKey];
57 };
58 
59 // Pre-evaluate all flags for dev tools
60 const allFlags: Record<string, EvalResult> = {};
61 for (const [key] of evaluator.getAllFlags()) {
62 allFlags[key] = evaluator.evaluate(key, context);
63 }
64 
65 return {
66 isEnabled: (flagKey: string) => getResult(flagKey).enabled,
67 getVariant: (flagKey: string) => getResult(flagKey).variant,
68 getResult,
69 isLoading,
70 allFlags,
71 };
72 }, [context, isLoading, version]);
73 
74 return <FlagContext.Provider value={value}>{children}</FlagContext.Provider>;
75}
76 
77export function useFlag(flagKey: string): boolean {
78 const ctx = useContext(FlagContext);
79 if (!ctx) throw new Error("useFlag must be used within FlagProvider");
80 return ctx.isEnabled(flagKey);
81}
82 
83export function useFlagVariant(flagKey: string): string {
84 const ctx = useContext(FlagContext);
85 if (!ctx) throw new Error("useFlagVariant must be used within FlagProvider");
86 return ctx.getVariant(flagKey);
87}
88 
89export function useFlagResult(flagKey: string): EvalResult {
90 const ctx = useContext(FlagContext);
91 if (!ctx) throw new Error("useFlagResult must be used within FlagProvider");
92 return ctx.getResult(flagKey);
93}
94 
95export function useFlagsLoading(): boolean {
96 const ctx = useContext(FlagContext);
97 if (!ctx) throw new Error("useFlagsLoading must be used within FlagProvider");
98 return ctx.isLoading;
99}
100 
101export function useAllFlags(): Record<string, EvalResult> {
102 const ctx = useContext(FlagContext);
103 if (!ctx) throw new Error("useAllFlags must be used within FlagProvider");
104 return ctx.allFlags;
105}
106 

Feature Component

typescript
1// src/components/Feature.tsx
2import { useFlag, useFlagVariant, useFlagsLoading } from "../lib/flags/FlagProvider";
3 
4interface FeatureProps {
5 flag: string;
6 children: React.ReactNode;
7 fallback?: React.ReactNode;
8 loading?: React.ReactNode;
9}
10 
11export function Feature({ flag, children, fallback = null, loading = null }: FeatureProps) {
12 const isLoading = useFlagsLoading();
13 const enabled = useFlag(flag);
14 
15 if (isLoading) return <>{loading}</>;
16 return enabled ? <>{children}</> : <>{fallback}</>;
17}
18 
19interface VariantProps {
20 flag: string;
21 variants: Record<string, React.ReactNode>;
22 fallback?: React.ReactNode;
23}
24 
25export function Variant({ flag, variants, fallback = null }: VariantProps) {
26 const variant = useFlagVariant(flag);
27 const enabled = useFlag(flag);
28 
29 if (!enabled) return <>{fallback}</>;
30 return <>{variants[variant] ?? fallback}</>;
31}
32 
33// Usage
34function CheckoutPage() {
35 return (
36 <Feature flag="new-checkout" fallback={<LegacyCheckout />}>
37 <Variant
38 flag="new-checkout"
39 variants={{
40 "single-page": <SinglePageCheckout />,
41 "multi-step": <MultiStepCheckout />,
42 }}
43 fallback={<LegacyCheckout />}
44 />
45 </Feature>
46 );
47}
48 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

App Integration

typescript
1// src/App.tsx
2import { FlagProvider } from "./lib/flags/FlagProvider";
3import { useAuth } from "./hooks/useAuth";
4 
5function App() {
6 const { user } = useAuth();
7 
8 const flagContext = {
9 userId: user?.id ?? "anonymous",
10 email: user?.email,
11 plan: user?.plan,
12 };
13 
14 return (
15 <FlagProvider
16 context={flagContext}
17 apiUrl={process.env.REACT_APP_API_URL!}
18 refreshInterval={30000}
19 >
20 <Router>
21 <Routes>
22 <Route path="/" element={<Home />} />
23 <Route path="/dashboard" element={<Dashboard />} />
24 <Route path="/checkout" element={<CheckoutPage />} />
25 </Routes>
26 </Router>
27 </FlagProvider>
28 );
29}
30 

Developer Tools Panel

typescript
1// src/components/FlagDevTools.tsx
2import { useAllFlags } from "../lib/flags/FlagProvider";
3import { useState } from "react";
4 
5export function FlagDevTools() {
6 const allFlags = useAllFlags();
7 const [isOpen, setIsOpen] = useState(false);
8 
9 if (process.env.NODE_ENV !== "development") return null;
10 
11 return (
12 <div style={{ position: "fixed", bottom: 10, right: 10, zIndex: 9999 }}>
13 <button onClick={() => setIsOpen(!isOpen)} style={{
14 padding: "8px 12px", background: "#1a1a1a", color: "#fff",
15 border: "1px solid #333", borderRadius: 6, cursor: "pointer",
16 }}>
17 Flags ({Object.keys(allFlags).length})
18 </button>
19 
20 {isOpen && (
21 <div style={{
22 position: "absolute", bottom: 40, right: 0, width: 360,
23 background: "#1a1a1a", border: "1px solid #333", borderRadius: 8,
24 padding: 16, maxHeight: 400, overflow: "auto",
25 }}>
26 <h3 style={{ color: "#fff", margin: "0 0 12px" }}>Feature Flags</h3>
27 {Object.entries(allFlags).map(([key, result]) => (
28 <div key={key} style={{
29 display: "flex", justifyContent: "space-between", alignItems: "center",
30 padding: "6px 0", borderBottom: "1px solid #333",
31 }}>
32 <span style={{ color: "#ccc", fontSize: 13 }}>{key}</span>
33 <span style={{
34 padding: "2px 8px", borderRadius: 4, fontSize: 11,
35 background: result.enabled ? "#065f46" : "#7f1d1d",
36 color: "#fff",
37 }}>
38 {result.enabled ? result.variant || "ON" : "OFF"}
39 </span>
40 </div>
41 ))}
42 </div>
43 )}
44 </div>
45 );
46}
47 

Testing

typescript
1// src/__tests__/Feature.test.tsx
2import { render, screen } from "@testing-library/react";
3import { FlagProvider } from "../lib/flags/FlagProvider";
4import { Feature } from "../components/Feature";
5 
6const mockFlags = [
7 { key: "test-feature", enabled: true, percentage: 100, rules: [], defaultVariant: "", type: "release" as const },
8 { key: "disabled-feature", enabled: false, percentage: 100, rules: [], defaultVariant: "", type: "release" as const },
9];
10 
11function renderWithFlags(ui: React.ReactElement) {
12 // Pre-load flags into evaluator
13 return render(
14 <FlagProvider context={{ userId: "test-user" }} apiUrl="/mock" refreshInterval={99999}>
15 {ui}
16 </FlagProvider>
17 );
18}
19 
20test("Feature renders children when flag is enabled", async () => {
21 renderWithFlags(
22 <Feature flag="test-feature">
23 <div>New Feature</div>
24 </Feature>
25 );
26 expect(await screen.findByText("New Feature")).toBeInTheDocument();
27});
28 
29test("Feature renders fallback when flag is disabled", async () => {
30 renderWithFlags(
31 <Feature flag="disabled-feature" fallback={<div>Old Feature</div>}>
32 <div>New Feature</div>
33 </Feature>
34 );
35 expect(await screen.findByText("Old Feature")).toBeInTheDocument();
36});
37 

Conclusion

A React feature flag system built with context and hooks provides a clean, composable API that React developers expect. The Feature and Variant components make conditional rendering declarative, the hooks (useFlag, useFlagVariant) integrate naturally with custom component logic, and the developer tools panel makes debugging flag states straightforward during development.

The architecture follows a clear data flow: flag configurations are fetched from the API, evaluated locally against the user context, cached per render cycle via useMemo, and exposed through React context. This approach avoids per-evaluation API calls, provides instant flag checks, and ensures consistent flag states within a single render.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026