Integrating Feature Flags in React Native

This how-to is part of Mobile and Native Flag Delivery. You are adding feature flags to a React Native app and the web patterns almost fit — but localStorage does not exist, the app suspends, and a background poll drains battery. This how-to wires an offline-first flag provider backed by AsyncStorage, a useFlag hook, and an AppState-driven refresh that reads instantly and fetches only when it should.

The core inversion from web to native is this: on the web you can usually afford to block the first paint on a flag fetch because the tab is a fresh process every navigation, but a native app is a long-lived process that users open and close dozens of times a day. If every cold start waited on the network, you would pay that latency — 300ms on a good connection, several seconds on a subway platform — over and over, and the app would feel broken to anyone offline. So the design goal flips: reads must be synchronous and local, and the network becomes a background reconciliation that never sits between the user and the UI. Every decision below follows from that single constraint.

React Native flag provider wiring A provider hydrates from AsyncStorage into memory at launch, exposes values through a useFlag hook that reads synchronously, and refreshes from the network when AppState transitions to active. AsyncStorage persisted set memory cache provider state useFlag() sync read network on active
AsyncStorage fills the memory cache at launch; the hook reads that cache synchronously; the network only refills the cache when the app becomes active.

Prerequisites

React Native prerequisites AsyncStorage installed, a bundled default set, and AppState access for foreground detection. AsyncStorage persistence Bundled defaults in the JS bundle AppState foreground event
AsyncStorage stands in for the web's localStorage — it is async, so hydration must complete before the first flag-dependent render.

Step-by-Step Procedure

Step 1 — Build the offline-first provider

Create a provider that hydrates from AsyncStorage into memory and exposes a synchronous getter, falling back through persisted values to bundled defaults. The three-layer fallback is deliberate: memory is the hot path every read hits, the persisted set is what survived the last session, and the bundled defaults are the floor that guarantees getFlag never returns undefined even on a first launch with an empty disk. Keep the bundled defaults compiled into the JS bundle rather than fetched — they are the values your QA actually signed off on for this build, so if the network never answers, the app still behaves like a tested release rather than a coin flip.

// flagProvider.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { BUNDLED_DEFAULTS } from './bundled-defaults';

let cache: Record<string, boolean> = { ...BUNDLED_DEFAULTS };

export async function hydrate() {
  const raw = await AsyncStorage.getItem('flags');
  if (raw) cache = { ...cache, ...JSON.parse(raw) };
}
export const getFlag = (k: string) => cache[k] ?? BUNDLED_DEFAULTS[k] ?? false;
export async function persist(fresh: Record<string, boolean>) {
  cache = { ...cache, ...fresh };
  await AsyncStorage.setItem('flags', JSON.stringify(fresh));
}

Step 2 — Hydrate before the first render

Gate the app’s first render on hydration so the initial screen reflects persisted values, not a flash of bundled defaults. The window you are closing is small — a single AsyncStorage read is a few milliseconds — but it is exactly the window in which your most-seen screen paints, so a mismatch is glaringly visible. Keep the splash strictly local and time-bounded: it should resolve on the hydrate promise alone and never wait on fetchFlags, or you have quietly reintroduced the network-blocking cold start you were trying to avoid. If hydration itself can fail — corrupt JSON, a storage read error — catch it and proceed with bundled defaults rather than hanging on the splash forever; a stale-but-tested UI beats an app that never gets past its logo.

// App.tsx
export default function App() {
  const [ready, setReady] = useState(false);
  useEffect(() => { hydrate().then(() => setReady(true)); }, []);
  if (!ready) return <SplashScreen />;   // brief, local — no network
  return <RootNavigator />;
}

Step 3 — Expose a useFlag hook

Provide a hook that reads the synchronous getter and re-renders when a refresh updates the cache, so screens react to new flag values without prop drilling. useSyncExternalStore is the right primitive here because it subscribes to an external mutable store — your memory cache — and re-renders only the components whose selected value changed, so a refresh that flips app.home.new-feed does not needlessly re-render every screen reading a different flag. Make sure the subscribe function returns an unsubscribe and that getFlag is referentially stable for an unchanged value; if you return a fresh object from the snapshot each call, React will loop trying to reconcile a store it thinks keeps changing. For flags read outside React — an analytics wrapper, a navigation guard — expose the plain getFlag getter too, so non-component code shares the exact same cache and can never disagree with what the UI is showing.

// useFlag.ts
import { useSyncExternalStore } from 'react';
import { getFlag, subscribe } from './flagProvider';

export function useFlag(key: string): boolean {
  return useSyncExternalStore(subscribe, () => getFlag(key));
}
// usage: const showNewFeed = useFlag('app.home.new-feed');

Step 4 — Refresh on AppState active

Fetch and persist when the app returns to the foreground and a refresh is due, skipping it on constrained connectivity. Foreground is the right trigger because it is the one moment you know the user is actually looking at the app, so a fetch there converts battery into value the user sees, whereas a timer that fires while the phone is in a pocket spends radio power on a screen no one will read this hour. Guard the fetch with a minimum interval — MIN_INTERVAL_MS of 5 to 15 minutes is a sane starting point — so a user who toggles between your app and their messages every few seconds does not trigger a fetch on every switch. Pair the interval with a connectivity check (via @react-native-community/netinfo) and skip the refresh on cellular if the user is on a metered plan or on a connection the OS reports as constrained; the cache already has a serviceable answer, so there is no urgency worth a failed request or a data charge. Budgeting that refresh cadence against battery and network is covered in depth in the sibling guide linked below.

// refresh.ts
import { AppState } from 'react-native';
AppState.addEventListener('change', async (state) => {
  if (state !== 'active') return;
  if (Date.now() - lastRefresh < MIN_INTERVAL_MS) return;
  const fresh = await fetchFlags();     // background, not blocking any read
  await persist(fresh);
});
The four React Native steps Build the offline-first provider, hydrate before the first render, expose a useFlag hook, and refresh on AppState active. 1 Provider offline-first 2 Hydrate before render 3 useFlag sync read 4 Refresh on active
The hook in step 3 leans on useSyncExternalStore so a background refresh re-renders exactly the screens reading the changed flag.

Verification

Run the app in the simulator with the network off and confirm flags resolve and the UI renders; then enable the network, background and foreground the app, and confirm a refresh persisted. The offline pass is the load-bearing one — it proves the read path never secretly depends on the network — so test it first, before any online behaviour. A useful third case sits between the two: launch offline, then bring the network up while the app is already in the foreground and confirm nothing refreshes until the next active transition, which verifies your trigger is genuinely lifecycle-driven and not a hidden interval. To simulate a truly cold start rather than a warm resume, fully terminate the app between runs (swipe it away, or adb shell am force-stop com.example.app) so hydration runs from disk instead of from a process that still holds the memory cache.

# With network disabled in the simulator, launch and check no crash and defaults render.
# Then re-enable, background/foreground, and inspect the persisted set:
adb shell run-as com.example.app cat files/AsyncStorage/flags 2>/dev/null | head
# -> the freshly fetched flag JSON, proving the foreground refresh persisted
Offline render then foreground refresh Offline, the app renders on cached and bundled values; after a foreground event online, AsyncStorage holds the freshly fetched set. offline: renders cache + defaults foreground: persisted fresh JSON on disk
Seeing the fresh JSON on disk after a foreground event proves the whole loop — hydrate, read, refresh, persist — is wired correctly.

Gotchas & Edge Cases

Three React Native gotchas Rendering before async hydration completes, AsyncStorage size limits on Android, and AppState firing on transient interruptions. Render before hydrate flash of defaults gate the first render Storage limits Android 6MB cap keep payload lean Spurious active permission dialogs debounce refresh
The spurious-active gotcha is easy to miss — a permission prompt briefly backgrounds the app, and its dismissal fires another active event you should debounce.

Troubleshooting & FAQ

The first screen flashes the old feature before showing the new one. Why?

You are rendering before hydration finishes, so the frame shows bundled defaults, then swaps to persisted values. Gate the root render on the hydrate promise resolving. The splash it shows is local and lasts a frame or two, not a network wait.

Can I use the OpenFeature React SDK directly on React Native?

The OpenFeature web SDK works in React Native with an appropriate provider, but the provider must persist to AsyncStorage rather than assume localStorage, and must not open a persistent stream by default. Wrap it with the offline-first cache from this guide so reads stay synchronous and refreshes stay lifecycle-driven.

How do I force a flag update without waiting for a foreground event?

Expose a manual refresh (a pull-to-refresh, or a call after a known state change like login) that runs the same fetch-and-persist path. Reserve it for moments where freshness genuinely matters; routine updates should still ride the foreground event to protect the battery.

How should evaluation context — like user ID — flow into the provider?

Pass context into the fetch, not into every read. The getFlag getter stays a pure key lookup against an already-evaluated set; when the context changes — a login, a plan upgrade — you re-fetch with the new context and persist the resulting values. That keeps the hot read path synchronous and moves all targeting to the one place that talks to the network. If you must evaluate rules on-device instead, keep the rule set small and treat context changes as an explicit refresh trigger rather than re-deriving on every render.

Should I use polling or a streaming connection on mobile?

Prefer lifecycle-driven polling on the foreground event over a persistent stream. A held-open SSE or WebSocket keeps the radio warm and is torn down every time the OS suspends the app, so on mobile it tends to cost battery for updates the user is not even present to see. Reserve streaming for the rare surface that genuinely needs sub-minute propagation while the app is open; for almost everything else, a foreground fetch guarded by a minimum interval delivers fresh-enough flags at a fraction of the power.

What happens to a rollout percentage if the device is offline for days?

The device keeps serving whatever set it last persisted, so its bucketing is frozen at that snapshot until the next successful fetch. This is usually fine — the assignment is sticky and consistent for that user — but it means a rollout you dial back on the server does not reach a long-offline device until it reconnects and foregrounds. Bound the staleness you can tolerate by having reads fall back to bundled defaults once a persisted set is older than a threshold you choose, if a runaway old value would be worse than a tested default.

Does the New Architecture or Expo change any of this?

No — the pattern is agnostic to the renderer. AsyncStorage, AppState, and useSyncExternalStore all work the same under the New Architecture and on both bare React Native and Expo. On Expo you may reach for expo-secure-store for sensitive values, but for ordinary flags plain AsyncStorage is the right tool; encrypting a non-secret payload only adds read latency to the hot path.