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.
Prerequisites
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);
});
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
Gotchas & Edge Cases
- Hydration is async — do not render flag-dependent UI first. AsyncStorage reads are promises; rendering before hydration shows bundled defaults for a frame. Gate the first render on a completed hydrate, using a brief local splash.
- AsyncStorage has size limits. Android’s default AsyncStorage caps around 6MB; a bloated flag payload can hit it. Keep the persisted set to the flags the app reads, not the entire catalog. See minimizing bundle size.
- AppState fires on transient interruptions. A permission dialog or a phone call briefly backgrounds the app; its return fires another
activeevent. Debounce the refresh with a minimum interval so a quick interruption does not trigger a redundant fetch. On iOS there is an extra state,inactive, that precedesbackgroundduring the app-switcher animation — filter foractiveexplicitly rather than assuming any change means resume. - A mid-session refresh can flip a flag under the user’s feet. If a fetch persists a new value while a screen is open,
useSyncExternalStorewill re-render it immediately, which can yank a control the user was mid-interaction with. For flags that gate a whole surface, snapshot the value at screen mount or route entry and let the change take effect on the next navigation, so the experience stays stable within a session rather than mutating live. - Persist the fetched set, not a merge, if the server owns deletions.
persisthere shallow-merges fresh values into the cache, which means a flag the server stopped sending lingers in AsyncStorage forever. If your backend can retire a flag, either send an explicit tombstone or replace the persisted set wholesale so removed keys fall back to bundled defaults instead of a stale on-disk value. - AsyncStorage writes are not transactional across keys. If you split the flag set across multiple keys, a crash between writes can leave them inconsistent. Store the whole set under one key — as the code above does — so a write is all-or-nothing and hydration never reads a half-updated state.
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.