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.

The distinction matters more than it first appears. A flag evaluation is not a pure function of the key — it depends on evaluation context (user id, plan tier, region), on the provider’s current configuration, and on connection state. When you scatter client.getBooleanValue() calls across the tree, each call site independently decides what default to pass and what context is in scope, and there is no single place to observe or override the result during an incident. Centralizing the read path means one seam for logging every evaluation, one place to swap the provider for a mock in tests, and one owner for the “is the SDK ready yet” question that every branch of your UI implicitly asks. Treat the provider as the boundary between “the rest of React knows nothing about flag vendors” and “exactly one module knows everything about them” — that boundary is what keeps a vendor migration to a one-file diff.

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).

Two failure modes versus the provider-and-hook pattern Scattered direct SDK calls drift out of sync; one monolithic context re-renders the whole tree; the provider-and-hook pattern centralizes the lifecycle while each hook subscribes to only its own value. Scattered SDK calls duplicated in dozens of components keys drift One mega-context any flag change re-renders everything wasted renders Provider + hook one lifecycle owner per-value subscribe targeted re-render
The pattern in this guide sits on the right: a single lifecycle owner, with each useFlag subscribing only to the value it reads.

Prerequisites

Prerequisites for the React hook pattern React 18 plus, the OpenFeature web SDK with a registered provider, and TypeScript strict mode. React 18+ concurrent-safe web-sdk + provider registered at boot TypeScript strict catches key drift
Strict-mode TypeScript is what turns a mistyped flag key into a build error rather than a silent default at runtime.

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.

Two properties of the OpenFeature Web SDK shape this architecture and are worth internalizing before you write a line of code. First, the Web SDK is static-context: evaluation context is set once globally with OpenFeature.setContext() rather than passed per-evaluation, which is why all your getBooleanValue calls can be synchronous and why context changes trigger a full re-fetch. Second, evaluation is local — the provider maintains an in-memory ruleset synced from the backend, so getBooleanValue never makes a network call and returns in microseconds. That combination is what makes a Context-based fan-out viable at all: if every hook read hit the network, no amount of memoization would save you. The re-render discipline in this guide is therefore about CPU and reconciliation cost inside React, not about network round-trips — a different problem than the one server-side SDKs solve.

FlagProvider OpenFeature.setProvider() · Context.Provider context value useFlag(key, default) selector memoization · typed return value <NewNav /> <ExpressCheckout /> <BetaBanner />
FlagProvider owns the OpenFeature client lifecycle. Each useFlag call creates an isolated subscription — only the component whose flag value changed re-renders.

Step-by-step implementation

The six steps build from the SDK up: register the provider, wrap the tree, expose a hook, trim re-renders, add loading boundaries, then guard SSR.

The six implementation steps Initialize the provider, build FlagProvider, write useFlag, add selector memoization, handle Suspense loading, then guard the SSR boundary. 1 Init SDK provider ready 2 Provider context 3 useFlag typed value 4 Selector fewer renders 5 Suspense loading 6 SSR guard no mismatch Steps 1–3 make flags usable; steps 4–6 make them fast and SSR-safe.
Each step is independently shippable — you can stop after step 3 and still have a working hook, then add memoization and SSR guards as scale demands.

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.

There is a real trade-off buried in that advice, though: awaiting setProviderAndWait delays your first paint by however long the provider takes to fetch its initial ruleset, which for a cold CDN edge can be 100–300 ms. If that delay pushes your Largest Contentful Paint past budget, do not block the whole app — instead render immediately with defaults and let the ready guard swap in flag-driven UI once the provider fires PROVIDER_READY. The right choice depends on how flag-heavy your above-the-fold content is: a marketing landing page with one experiment can afford to wait; a dashboard whose entire shell is flagged should paint a skeleton first. Set a timeout on setProviderAndWait (the SDK accepts an options argument with a millisecond deadline) so a stalled provider connection cannot hang your mount indefinitely — on timeout the SDK proceeds with defaults and emits PROVIDER_ERROR, which you can surface to observability rather than to the user.


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);
}

One detail in the reducer is load-bearing: the UPDATE action merges ({ ...state.flags, ...action.flags }) rather than replacing. A PROVIDER_CONFIGURATION_CHANGED event may carry a partial delta — only the flags that actually changed — and if you overwrite the whole map you can momentarily drop flags that were not part of the delta, flickering their consumers back to defaults. Merging keeps every previously-known value stable across partial updates. Note also that snapshot() re-evaluates every watched flag on each event; that is deliberate simplicity for small flag sets, and Step 4 plus the Performance section explain when to graduate to per-key listeners so an unrelated flag flip stops re-running the whole batch.

The guard OpenFeature.providerMetadata.name !== 'No-op Provider' handles the race where initFlags resolved before this effect ran, so PROVIDER_READY already fired and your handler would otherwise never be called. Without it, a fast provider leaves ready stuck at false forever and every consumer renders its skeleton indefinitely — a failure mode that only appears on warm caches and therefore slips through local testing where the provider is always cold.

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. The name is also the domain scope OpenFeature uses to bind a client to a specific provider, so if you later register a second provider for a subset of flags, that binding is keyed off exactly this string — keep it in a shared constant rather than typing the literal 'app' at every call site.


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 />;
}

The ready flag returned alongside value is not decoration — it lets each consumer distinguish “the flag is genuinely off” from “we do not know yet.” Those two states usually want different UI: a definite false renders the legacy path, while not-yet-ready renders a skeleton that avoids committing to either branch. Collapsing them (treating not-ready as off) is the single most common source of the “new feature flashed off then on” bug users report, because the component confidently renders the off branch during the sub-second window before the provider reports ready.

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. The generic constraint T extends boolean | string | number deliberately forbids object defaults at the type level for exactly this reason — if you find yourself wanting a JSON flag, evaluate it once in the provider and expose the parsed shape through a dedicated hook rather than threading an object default through useFlag.


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.

Be precise about what this does and does not save. The useFlagContext() call still fires for every subscriber on every context change — React has no way to skip a context read. What useFlagSelector prevents is the downstream work: because the hook returns the same reference when the selected slice is unchanged, the component’s own render produces identical output and any React.memo-wrapped children are skipped. So the hook eliminates reconciliation and child rendering, not the context notification itself. For a page with a handful of subscribers this is immaterial; it becomes worth the added indirection once you have dozens of components reading from the same provider, where the multiplied reconciliation cost of a single flag flip starts showing up as dropped frames in the React Profiler. The custom isEqual parameter earns its keep when the selected value is a derived object — for example selecting { variant, payload } out of a multivariate flag — where Object.is would report inequality on every event even though the fields are unchanged.

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.

Pitfall: Mutating the value inside a selector — sorting an array in place, say — corrupts the shared flag map because flags is the live context object, not a copy. Selectors must be pure reads; if you need a transformed shape, build a new value rather than mutating the argument.


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.

There is a deliberate optimization hiding in the lazy() split: the code for a flag that is currently off never enters the user’s bundle at all. A flag gating a heavy checkout widget, an experimental editor, or a vendor SDK that weighs 80 KB gzipped stays out of the critical path for the majority who see the off variant, and only downloads for the cohort the flag actually enables. This turns feature flags into a code-splitting boundary as much as a behavior switch — a property that pays off most when you are dark-launching something large. One caveat: prefetch the lazy chunk (import('./ExpressPayWidget') on hover or on ready) if the flip needs to feel instant, otherwise the first user in the enabled cohort waits on a network fetch at the moment the flag turns on.

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.

Pitfall: A Suspense fallback that differs structurally from the resolved content — different height, different layout — produces a Cumulative Layout Shift when the real widget swaps in. Size your skeleton to the widget’s committed dimensions so the flip does not push surrounding content around.


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.

Understand precisely why the mismatch happens so you can reason about the fix rather than cargo-culting it. React hydration is a diff between the server-rendered HTML string and the first client render’s output; if any node differs, React discards the server markup for that subtree and re-renders from scratch, logging a hydration warning. A flag that resolves to its default on the server (no-op client) but to true in the browser produces exactly that divergence. The mounted guard sidesteps it by making the first client render identical to the server render — both render the default branch — and only introducing flag-driven output on the second render, which is a normal update rather than a hydration diff. The cost is one extra client render and a brief default-value frame; the benefit is that you never ship broken, partially-hydrated markup. If that frame is unacceptable, the escape hatch is to serialize server-evaluated flag values into the initial HTML and seed the provider with them, covered in the App Router hydration guide linked below.

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.

Pitfall: Do not wrap the guard around a null return during SSR (if (!mounted) return null). Rendering nothing on the server means the server HTML omits the subtree entirely, so hydration still diffs an empty region against a populated one — and you lose all above-the-fold content to the crawler. Render {children} with defaults, not null.


Verification and testing

Deterministic hook testing with a mock provider A mock FlagContext provider supplies fixed flag values so tests assert the on branch, the off branch, and the not-ready skeleton without any network call. MockFlagProvider fixed flag map flag=true → NewNav flag=false → LegacyNav ready=false → Skeleton tsc --noEmit catches key drift
Three assertions cover the whole surface — on, off, and not-ready — and the type-check gate keeps flag keys from silently drifting.

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.

Should I put every flag read behind useFlag, or is direct client access ever fine?

Route every read that drives rendered output through useFlag or useFlagSelector — that is what makes re-renders reactive to configuration changes and keeps the type-checked key surface in one place. Direct client.getBooleanValue() calls are acceptable only in imperative, non-render code: an event handler deciding which analytics event to fire, a one-shot check inside a useEffect, or a data-loading branch. The rule of thumb is that if the value influences JSX, it belongs in a hook so a mid-session flag flip re-renders the component; if it only influences a side effect, a direct read is fine and avoids adding a subscription you do not need.

How do I update the evaluation context after login without a full remount?

Call OpenFeature.setContext() with the enriched context (user id, plan, region) when the user authenticates. The Web SDK re-evaluates its ruleset against the new context and fires PROVIDER_CONFIGURATION_CHANGED, which your existing ConfigurationChanged handler already listens for — so the flag map refreshes and dependent components re-render with no remount. Do not tear down and recreate FlagProvider on login; that drops every subscription and re-runs setProviderAndWait. The one thing to watch is that setContext is asynchronous while evaluation is resolving, so guard against reading flags in the brief window before the new context settles by keeping consumers on their previous value until the change event lands.

Why do my flag values differ between two tabs of the same app?

Each browser tab runs its own instance of the Web SDK with its own in-memory ruleset, and the two sync from the backend independently. If you flip a flag in your dashboard, the tab whose streaming connection delivers the update first reflects it seconds before the other. This is expected for client-side evaluation and rarely matters, but if you need cross-tab consistency — a kill switch that must hit every tab at once — broadcast configuration changes over the BroadcastChannel API or storage events so one tab’s update wakes the others. Do not rely on tabs converging on their own polling interval for anything safety-critical.

Can I read a flag inside a class component or outside the React tree?

Hooks only work inside function components, so a legacy class component cannot call useFlag. Bridge it by reading the value in a function-component wrapper and passing it down as a prop, or by using a render-prop consumer of FlagContext. For truly non-React code — a Redux middleware, a route loader, a service module — call OpenFeature.getClient('app').getBooleanValue() directly; the client is a module-level singleton reachable from anywhere. Just remember those imperative reads are point-in-time snapshots that do not react to later configuration changes, so re-read them wherever freshness matters rather than caching the result.

Performance and scale

Snapshot cost versus per-key listeners A full snapshot re-evaluates every watched flag on each change event, costing order-n; per-key listeners re-evaluate only the changed flag, costing order-one. Full snapshot re-evaluates all watched flags O(n) per event Per-key listeners only the changed key re-runs O(1) per change
Past roughly 20 flags, switch from the snapshot approach to keyed listeners so an unrelated flag flip no longer re-evaluates the whole set.

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.

Watch the event fan-out separately from evaluation cost. Every consumer of FlagContext is notified on every context value change regardless of whether its specific flag moved, and each notification triggers React to re-run that component’s render function up to the point where memoization bails out. With the snapshot approach a single flag flip therefore does O(subscribers) render invocations even though only O(1) of them produce different output. That is invisible below a few dozen subscribers and measurable above a few hundred — profile with the React DevTools “highlight updates” overlay before optimizing, because premature context-splitting adds indirection you may never need. When you do split, the cheapest win is usually isolating the ready boolean into its own context so the many components that only gate on readiness stop re-rendering when flag values change.

Also budget for the initial sync payload. The provider downloads the full ruleset for every watched flag at boot, so a project that has accumulated hundreds of stale flags pays that transfer and parse cost on every cold load. Prune retired flags on a cadence and scope the client to the flags a given surface actually reads — the flag count you never clean up is a latency tax paid by every first-time visitor.

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.