Feature Flags on Mobile
A cookbook for LaunchDarkly (or equivalent remote config) combined with EAS Update channels - controlled exposure patterns for enterprise mobile teams shipping Expo SDK 57 apps through store review and daily OTA trains.
Busque em todas as páginas da documentação
A cookbook for LaunchDarkly (or equivalent remote config) combined with EAS Update channels - controlled exposure patterns for enterprise mobile teams shipping Expo SDK 57 apps through store review and daily OTA trains.
Quick-reference recipe card - copy-paste ready.
npm install @launchdarkly/react-native-client-sdk// src/flags/launchDarkly.ts
import {
ReactNativeLDClient,
AutoEnvAttributes,
} from "@launchdarkly/react-native-client-sdk";
const client = new ReactNativeLDClient(
process.env.EXPO_PUBLIC_LAUNCHDARKLY_MOBILE_KEY!,
AutoEnvAttributes.Enabled,
);
export async function initFlags(user: { key: string; email?: string }) {
await client.identify({ kind: "user", key: user.key, email: user.email });
return client;
}
export function isEnabled(flag: string, defaultValue = false): boolean {
return client.boolVariation(flag, defaultValue);
}// src/flags/FlagGate.tsx
import { type ReactNode } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import { useBoolVariation } from "@launchdarkly/react-native-client-sdk";
type Props = { flag: string; fallback?: ReactNode; children: ReactNode };
export function FlagGate({ flag, fallback, children }: Props) {
const enabled = useBoolVariation(flag, false); // safe default: off
if (enabled === undefined) {
return <ActivityIndicator accessibilityLabel="Loading feature" />;
}
if (!enabled) {
return (
fallback ?? (
<View style={{ flex: 1, justifyContent: "center", padding: 24 }}>
<Text>This feature is temporarily unavailable.</Text>
</View>
)
);
}
return <>{children}</>;
}When to reach for this:
When to avoid:
app.config.ts or EAS environment variables.runtimeVersion discipline - flags cannot add native modules OTA cannot ship.End-to-end LaunchDarkly + EAS Update channels for a checkout v3 rollout across store review, OTA, and staged flag ramp.
EAS Update channels segment which JS bundle a binary receives. LaunchDarkly flags segment behavior within that bundle.
// eas.json
{
"build": {
"preview": { "distribution": "internal", "channel": "preview" },
"production": { "channel": "production" }
}
}| Layer | Tool | Purpose |
|-------|------|---------|
| Binary | eas build profile + channel | Which update stream the app listens to |
| Bundle | eas update --channel | Which JS version is on that stream |
| Behavior | LaunchDarkly flag | Whether checkout_v3 UI renders |Related: Release Channels & Branches
// src/flags/bootstrap.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Updates from "expo-updates";
import { initFlags, isEnabled } from "./launchDarkly";
const CACHE_KEY = "flags:v1";
export async function bootstrapFlags(userKey: string) {
const cached = await AsyncStorage.getItem(CACHE_KEY);
try {
await initFlags({ key: userKey });
const snapshot = {
checkout_v3: isEnabled("checkout_v3", false),
api_v3: isEnabled("api_v3", false),
};
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(snapshot));
return snapshot;
} catch {
// Config endpoint down - use cache so app still launches
return cached ? JSON.parse(cached) : { checkout_v3: false, api_v3: false };
}
}// App.tsx - tag analytics with channel + updateId for incident correlation
import * as Updates from "expo-updates";
void Updates.updateId; // send to Sentry scope on initUpdates.updateId to correlate flag ramps with OTA publishes// app/(tabs)/checkout.tsx
import { FlagGate } from "@/flags/FlagGate";
import { CheckoutV3Screen } from "@/features/checkout/CheckoutV3Screen";
import { LegacyCheckoutScreen } from "@/features/checkout/LegacyCheckoutScreen";
export default function CheckoutRoute() {
return (
<FlagGate flag="checkout_v3" fallback={<LegacyCheckoutScreen />}>
<CheckoutV3Screen />
</FlagGate>
);
}// src/features/checkout/CheckoutV3Screen.tsx - also gate API version
import { useBoolVariation } from "@launchdarkly/react-native-client-sdk";
export function CheckoutV3Screen() {
const useApiV3 = useBoolVariation("api_v3", false);
const client = useApiV3 ? apiV3 : apiV2;
// ...
}checkout_v3 (UI) and api_v3 (backend) - ramp independentlyapi_v3 at 0%; mobile OTA ships UI hidden; backend ramps firstLaunchDarkly targeting rules (console):
1. Segment "employees" → checkout_v3: true (email ends with @company.com)
2. Segment "preview_builds" → checkout_v3: true (custom: channel == preview)
3. Default rule → checkout_v3: false// Pass custom attributes at identify time
await client.identify({
kind: "user",
key: user.id,
email: user.email,
custom: {
channel: Updates.channel ?? "unknown",
runtimeVersion: Updates.runtimeVersion ?? "unknown",
appVersion: Application.nativeApplicationVersion,
},
});channel == preview lets QA test on preview builds without affecting production users--rollout-percentage - use both deliberately# Ship JS to preview channel first - flags off for external users
eas update --channel preview --environment preview \
--message "feat: checkout v3 UI (flag off)"
# After preview soak - publish to production channel; flag still off
eas update --channel production --environment production \
--message "promote: checkout v3 bundle (flag off)"
# Ramp flag in LaunchDarkly: 1% → 10% → 50% → 100%
# Monitor crash-free and payment success at each step| Phase | EAS channel | LaunchDarkly checkout_v3 | Audience |
|---|---|---|---|
| Preview soak | preview | true (employees only) | QA + staff |
| Production bundle | production | false | Everyone gets JS; UI hidden |
| Canary | production | 10% | Cohort sees v3 |
| GA | production | 100% | Full rollout |
Incident: checkout_v3 payment failures spike
T+0 LaunchDarkly: checkout_v3 → false (global off)
T+2 Verify payment success rate recovers
T+5 If bundle defect: eas update:rollback on production channel
T+60 Monitor crash-free for one hour# Parallel: confirm which bundle is live
eas channel:view production
eas update:list --branch production --limit 3| Need | Use EAS Update channel | Use LaunchDarkly flag |
|---|---|---|
| Different JS for QA vs prod binaries | ✅ preview vs production | ❌ |
| Hide feature on same bundle | ❌ | ✅ checkout_v3: false |
| Percentage of users on new UI | Partial (--rollout-percentage) | ✅ finer cohort rules |
| Instant kill switch | Slow (rollback minutes) | ✅ seconds |
| A/B test copy on same bundle | ❌ | ✅ multivariate flags |
| Ship JS during App Review | ✅ channel + flag off | ✅ flag off |
Teams without LaunchDarkly can use a lightweight flags endpoint:
// src/flags/remoteConfig.ts
const FLAGS_URL = process.env.EXPO_PUBLIC_FLAGS_URL!;
export async function fetchFlags(): Promise<Record<string, boolean>> {
const res = await fetch(FLAGS_URL, { headers: { "Cache-Control": "no-cache" } });
if (!res.ok) throw new Error("flags unavailable");
return res.json();
}Remote only. Hard-coding const CHECKOUT_V3 = true in JS requires OTA to flip - defeating the kill-switch purpose. Default off in code; remote service enables.
Yes, with AsyncStorage cache from the last successful fetch. New installs with no cache use safe defaults (off). Document offline behavior for field-sales apps.
Flags can gate whether native modules are invoked - not add modules absent from the binary. Missing native module still crashes if flag flips on without Clock A build.
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Revisado por Chris St. John·Última atualização: 16 de jul. de 2026