Klor.

Feature flags in an Expo app

Last updated 20 September 2026

Expo specific setup, the two hooks React Native needs that the web does not, and how flags relate to OTA updates, which solve a different problem than people expect.

Flags and OTA updates are not the same thing

Expo's over the air updates ship new JavaScript. Feature flags change a value the JavaScript already reads. They overlap enough to be confused and they fail differently.

  • An OTA update is a deploy. It carries code, it is all or nothing per user, and rolling it back means shipping another update.
  • A flag is a value. It changes in seconds, can apply to a percentage of users, and rolls back by being set to what it was.
  • An OTA update cannot help you at all when the problem is in native code, because that needs a store release. A flag can still turn the feature off.

Use both. Ship code with OTA, control it with a flag, and keep the kill switch on the side that does not need a build.

Install

npx expo install @klor/react @react-native-async-storage/async-storage

@klor/react has no React Native dependency of its own, not AsyncStorage, not MMKV, not react-native. That is what lets one package serve Expo, bare React Native, the browser and your server without a native module or a config plugin. The cost is that you pass in the two things only your app knows how to provide.

The two hooks React Native needs

app/_layout.tsx
import { AppState } from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { KlorProvider, createKlorClient } from '@klor/react'

const klor = createKlorClient({
apiKey: process.env.EXPO_PUBLIC_KLOR_PUBLIC_KEY!,

// Without this, a cold start with no signal serves fallbacks instead of the
// last configuration the device had. On mobile that is the common case.
storage: AsyncStorage,

// Coming back to the app is when config is most likely to be stale.
subscribeToForeground: (refresh) => {
const sub = AppState.addEventListener('change', (s) => s === 'active' && refresh())
return () => sub.remove()
},

// Leaving is when a buffered usage counter would otherwise be lost.
subscribeToBackground: (flush) => {
const sub = AppState.addEventListener('change', (s) => s !== 'active' && flush())
return () => sub.remove()
},
})

subscribeToBackground is the one people skip. The web gets the same behaviour free from visibilitychange, which React Native does not have, so without it an app backgrounded inside the flush interval loses everything it counted. That is most sessions.

Environments and EAS build profiles

A Klor key is scoped to one environment, so the natural mapping is one key per build profile. Put the development key in the development and preview profiles and the production key in production, and nothing in the app has to know which it is.

eas.json
{
"build": {
"development": { "env": { "EXPO_PUBLIC_KLOR_PUBLIC_KEY": "klor_pub_dev…" } },
"preview": { "env": { "EXPO_PUBLIC_KLOR_PUBLIC_KEY": "klor_pub_staging…" } },
"production": { "env": { "EXPO_PUBLIC_KLOR_PUBLIC_KEY": "klor_pub_prod…" } }
}
}

The EXPO_PUBLIC_ prefix means the value is embedded in the bundle, which is correct here and worth being deliberate about: a public key is meant to ship, and the payload behind it is treated as world readable. Flags you mark sensitive are stripped from it entirely. A private or management key must never appear in an app.

Reading a flag

const checkout = useFlag('checkout_v2', false)

Synchronous, with no loading state to handle: it returns the fallback until the first snapshot arrives, so a cold start or a dead network is never a crash and never a spinner. The second argument is what the app does if Klor is unreachable, so choose the behaviour you would want if it were not installed at all.

Targeting is evaluated on the device. The user id, country and app version you pass as context never leave the app, which also means a rollout works offline as long as the device has a cached snapshot.

Getting the version right, which is where gating breaks

If you use update gating, the version you pass is the whole input, and Expo hands you several values that are not the same thing.

  • Constants.expoConfig.version is the version in app.json at build time. This is the marketing version, and usually the one to compare against.
  • Application.nativeApplicationVersion from expo-application reads it from the installed binary instead, which stays truthful after an OTA update has changed the JavaScript but not the shell.
  • Application.nativeBuildVersion is the build number, CFBundleVersion on iOS and versionCode on Android. This is what tells 2.3.1 (456) apart from 2.3.1 (457).
  • Updates.runtimeVersion is about OTA compatibility and has nothing to do with what a user thinks the app version is. Do not gate on it.
context.ts
import * as Application from 'expo-application'
import { Platform } from 'react-native'

const context = {
userId: user.id,
attributes: {
platform: Platform.OS,
appVersion: Application.nativeApplicationVersion ?? '0.0.0',
},
}

Reading from the binary rather than from the config matters specifically because of OTA updates. After one, the JavaScript is new and the shell is not, so a version read from the bundle would claim a build the user does not have installed.

Checking it in a simulator

Run the app, change the flag in the dashboard, publish, then background and foreground the app. The foreground hook triggers a refresh and the new value is there. If it is not, check that the key belongs to the environment you published to: a key scoped to staging will never see a production publish, and that is the first thing to rule out.