In React Native, how does useColorScheme report the system light or dark setting, and why prefer it over Appearance.getColorScheme()?
answer
- a hook versus a snapshot
- 'light', 'dark' or null
- re-renders on every scheme change
- sunset schedules and app overrides
- addChangeListener outside components
basics
~10 suseColorScheme returns 'light' or 'dark' (null only without the native Appearance module) and re-renders the component whenever the scheme changes. Appearance.getColorScheme() returns the same value once, so a stored result goes stale.
solid answer
~40 s`useColorScheme()` from `react-native` returns the **active** color scheme, `'light'` or `'dark'`, and `null` only when the native Appearance module is missing, for example on some out-of-tree platforms. It is built on `useSyncExternalStore` over `Appearance.addChangeListener`, so the component re-renders whenever the scheme changes: the user flips the system setting, a sunset schedule switches to dark, or the app calls `Appearance.setColorScheme()`. `Appearance.getColorScheme()` returns the same value but only at the moment you call it, so a value cached at module scope or in a ref goes stale. Use the hook in components, and `Appearance.addChangeListener`, which returns a subscription with `remove()`, in non-React code. In 0.87 the hook no longer returns `'unspecified'`; treat `null` as your default scheme.
code
tsx · 16 linesimport { StyleSheet, Text, View, useColorScheme } from 'react-native';
export function BalanceCard({ balance }: { balance: string }) {
const scheme = useColorScheme();
const dark = scheme === 'dark';
return (
<View style={[styles.card, { backgroundColor: dark ? '#1c1f24' : '#ffffff' }]}>
<Text style={[styles.amount, { color: dark ? '#f2f4f7' : '#11151a' }]}>{balance}</Text>
</View>
);
}
const styles = StyleSheet.create({
card: { padding: 20, borderRadius: 12 },
amount: { fontSize: 28, fontWeight: '700' },
});go deeper
Recall that useColorScheme returns 'light' or 'dark' and re-renders on change, while getColorScheme returns a one-off value.
Explain how the hook subscribes through addChangeListener and useSyncExternalStore, which events trigger it, and what null means.
Structure theming so one subscription feeds the whole app, and remove stale module-scope or state-captured scheme reads from a codebase.
Decide whether the product follows the system only or offers an in-app choice, and how that choice interacts with scheduled system switches.
## Two ways to read the scheme React Native exposes the user's appearance preference through the `Appearance` module, modelled on the web's `prefers-color-scheme`. On Android it maps to the system Dark theme (Android 10 and later), and on iOS to Dark Mode (iOS 13 and later). | API | Kind | Updates the UI when the scheme changes | |---|---|---| | `useColorScheme()` | React hook | Yes, re-renders the component | | `Appearance.getColorScheme()` | Function | No, returns the value at call time | | `Appearance.addChangeListener(listener)` | Subscription | Calls the listener; you decide what to update | The docs recommend the hook for components, and warn that styles or logic depending on the scheme should not cache `getColorScheme()`, because the value can change while the app runs. ## Return values - `'light'`: the light scheme is active. - `'dark'`: the dark scheme is active. - `null`: only when the native Appearance module is unavailable, which in practice means some out-of-tree platforms. Since React Native 0.87 the hook's return type is `ColorSchemeName | null` and it **no longer returns `'unspecified'`**. Code written for older versions that checked for `'unspecified'` can drop that branch; a simple `scheme === 'dark'` treats `null` as light. ## How the hook stays current The implementation is two lines of substance: 1. A `subscribe` function calls `Appearance.addChangeListener(onStoreChange)` and returns a cleanup that calls `remove()` on the subscription. 2. The hook returns `useSyncExternalStore(subscribe, getColorScheme)`. `useSyncExternalStore` re-reads `getColorScheme()` whenever the listener fires and re-renders only if the value changed. The listener fires for three kinds of change: - the user switches the system setting, - a scheduled switch, such as dark at sunset and light at sunrise, - the app itself calls `Appearance.setColorScheme('light' | 'dark' | 'auto')`. ## A banking app that follows the system For a banking app that simply follows the phone, the root of the theme is one hook call: ```tsx const scheme = useColorScheme(); const palette = scheme === 'dark' ? darkPalette : lightPalette; ``` Everything else, the balance card, the transaction list, the transfer form, reads its colours from that palette, usually through a context so that only one component subscribes. When the user's phone switches to dark at sunset, the hook fires once, the palette changes, and the screens re-render with the new colours. ## Outside React components Some code needs the scheme but is not a component, such as a chart library configured imperatively or a function that builds a native notification's colours: - Read the current value with `Appearance.getColorScheme()` at the moment you need it. - Subscribe with `const sub = Appearance.addChangeListener(({ colorScheme }) => { ... })`. - Call `sub.remove()` when you no longer need updates. On iOS and Android the `colorScheme` passed to the listener is always `'light'` or `'dark'`. ## Common mistakes - **Module-scope reads**: `const isDark = Appearance.getColorScheme() === 'dark'` at the top of a file is evaluated once, at import. - **Stuffing the value into state**: `useState(Appearance.getColorScheme())` captures the initial value and never updates. - **Assuming null means dark or means an error**: it only means the native module is absent. - **Subscribing in every component**: many components calling the hook works, but a single provider at the root is simpler and keeps the colour logic in one place. - **Forgetting the native configuration**: an app config or native project that restricts the app to the light style makes the reported scheme stay light even when the system is dark. ## Testing scheme changes without touching the device Since React Native 0.86, React Native DevTools can **emulate light and dark mode** from its command palette, so you can flip the scheme while stepping through a screen without opening the device's settings. The emulation is temporary and resets when DevTools disconnects. It complements, rather than replaces, a real test on devices: - Flip the real system setting with the app open to exercise the native path. - Relaunch in dark mode to check the first frame. - Leave the app running across a scheduled switch if the design depends on it.
- Why does useColorScheme use useSyncExternalStore instead of useState plus an effect?The scheme is external state owned by the native Appearance module. `useSyncExternalStore` subscribes before the value is read for commit, so a change between render and subscription cannot be missed, and every component reading it during one render sees the same value. A `useState` initialised from `getColorScheme()` needs extra code to catch changes that happen before its effect subscribes.
- What should a component do when useColorScheme returns null?Treat it as the app's default scheme, usually light. `null` means only that the native Appearance module is unavailable, which on iOS and Android does not happen in practice. A check such as `scheme === 'dark'` handles it naturally, with no separate branch.
saying these in an interview costs you the question
- Appearance.getColorScheme() re-renders the component when the scheme changes.
- useColorScheme returns null when the user has not chosen a scheme.
- Reading the scheme once at app start is enough, since it only changes on restart.
- useColorScheme still returns 'unspecified' in React Native 0.87.
- The hook only reacts to the system setting, not to Appearance.setColorScheme calls.