Feature Flags and Remote Config
Merge code that is switched off, turn it on without a store release, and delete the flag once it has stuck.
const flag = useFeatureFlag("flag-key") // boolean | string | undefinedA feature flag is a value read at runtime that decides whether a code path runs. Remote config is the same mechanism used for values rather than switches: a page size, a support URL, a minimum supported version.
Why this matters more on mobile
You do not control when a user updates. A build released today may still be running on someone's phone in a year. Code behind a flag can be merged and shipped dark in a normal release, then enabled for 1% of users with no new binary and no review queue. If it misbehaves you turn it off, which is faster than any rollback.
Three states, not two
A flag is on, off, or not answered yet. The third state is what breaks screens: at first render the SDK has no value and useFeatureFlag returns undefined. The example renders one centred line of text and picks its branch from the flag, showing a placeholder while the value is still unknown. Resolve a default with flag ?? false, which is the same as flag === null || flag === undefined ? false : flag.
Not the same as an update channel
An EAS Update channel decides which builds receive which JavaScript bundle. A flag decides what a running build does. Flags need no download, work offline from the last cached value, and can target a slice of users.
Delete them
A flag is temporary. Once a feature is at 100% and has held, remove the flag and the old branch in the same commit, or every screen slowly grows two of everything.
Example
// app/checkout.tsx
import { StyleSheet, Text, View } from "react-native";
import { useFeatureFlag } from "posthog-react-native";
function LegacyCheckout() {
return <Text style={styles.label}>Checkout (three steps)</Text>;
}
function NewCheckout() {
return <Text style={styles.label}>Checkout (one page)</Text>;
}
export default function Checkout() {
// boolean | string | undefined. undefined means "flags not loaded yet".
const flag = useFeatureFlag("one-page-checkout");
// The hook runs before any branch, so hook order never changes.
return (
<View style={styles.center}>
{flag === undefined ? (
<Text style={styles.label}>Loading</Text>
) : flag === true ? (
<NewCheckout />
) : (
<LegacyCheckout /> // the default path is the OLD path
)}
</View>
);
}
const styles = StyleSheet.create({
// flexDirection defaults to column here, not row as in CSS
center: { flex: 1, alignItems: "center", justifyContent: "center" },
label: { fontSize: 16 },
});When to use it
- Merge a half-finished redesign to main without it being visible to users.
- Enable a new payment provider for 5% of traffic and watch for errors.
- Turn off an endpoint-heavy screen when the backend is struggling, with no release.
- Change a support URL or page size without shipping a build.
More examples
Remote config from your own endpoint, cached for offline
Use this when you do not want a third-party SDK, or you need config the backend already knows. The cache is what makes it usable on a train: the app starts with the last known values instead of the shipped defaults, and the cancelled flag stops a late response writing state into an unmounted provider.
// lib/config.tsx
import { createContext, useContext, useEffect, useState } from "react";
import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
type Config = { showReferralBanner: boolean; maxUploadMb: number };
const DEFAULTS: Config = { showReferralBanner: false, maxUploadMb: 10 };
const ConfigContext = createContext<Config>(DEFAULTS);
export const useConfig = () => useContext(ConfigContext);
export function ConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<Config>(DEFAULTS);
useEffect(() => {
let cancelled = false;
(async () => {
const cached = await AsyncStorage.getItem("config");
if (cached && !cancelled) {
setConfig({ ...DEFAULTS, ...JSON.parse(cached) });
}
try {
const res = await fetch("https://api.example.com/v1/config");
const fresh = (await res.json()) as Partial<Config>;
if (cancelled) return;
// Spreading DEFAULTS first means a key the server omits keeps its default.
setConfig({ ...DEFAULTS, ...fresh });
await AsyncStorage.setItem("config", JSON.stringify(fresh));
} catch {
// Offline: keep the cached or default values, do not clear them.
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>
);
}Resolving a flag value safely
A boolean helper over a flag store that may hold strings. A multivariate flag returns its variant name, so treating it as a boolean would make "variant-b" truthy and silently enable something; read those with getFeatureFlag and compare the variant by name instead.
// lib/flags.ts
export type Flags = Record<string, boolean | string | undefined>;
export function isEnabled(flags: Flags, key: string, fallback = false): boolean {
const value = flags[key];
// value ?? fallback is the same as:
// value === null || value === undefined ? fallback : value
return (value ?? fallback) === true;
}
const remote: Flags = { "one-page-checkout": true, "home-hero": "variant-b" };
console.log(isEnabled(remote, "one-page-checkout"));
// Output: true
console.log(isEnabled(remote, "home-hero"));
// Output: false
console.log(isEnabled(remote, "never-shipped"));
// Output: falseRemoving the flag after the rollout
The cleanup step people skip. Leave the flag in and the dead branch stays compiled into the binary, keeps appearing in code review, and eventually someone flips a flag nobody remembers. Put a removal date on the flag when you create it and delete the dashboard entry last, so an old build still on a phone cannot see a flag that suddenly reads undefined.
// app/checkout.tsx - after one-page-checkout has been at 100% for two releases
import { StyleSheet, Text, View } from "react-native";
// Gone in the same commit: the useFeatureFlag call, the undefined branch,
// LegacyCheckout, its styles, its tests, and the flag in the dashboard.
export default function Checkout() {
return (
<View style={styles.center}>
<Text style={styles.label}>Checkout (one page)</Text>
</View>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center" },
label: { fontSize: 16 },
});
Discussion