Most "dark mode in React Native" tutorials stop at dark: classes and call it done. But a real setting needs three states — Light, Dark, and System — it has to survive an app restart, and the native chrome (tab bar, navigation header) has to follow along, not just your own views. Here's the full thing, and the nice surprise is how little wiring the last part takes.
TL;DR — Define your colors once as a palette with light + dark stops in
tailwind.config, so every class is a pair (bg-page dark:bg-page-dark). Then a single call — NativeWind'scolorScheme.set(pref)— applies the user's choice. It drives both thedark:variants and React Native'sAppearance, so expo-router's theme and the native tab bar switch for free. Hold the choice in Redux, persist it to AsyncStorage, and restore it on launch.
(Part 6 of Building CannyCart, a voice-first shopping app I'm building in public. This one's a self-contained Expo how-to — no earlier context needed.)
Who this is for
You're on Expo + NativeWind and you already sprinkle dark: classes around. You want to turn that into an actual Appearance setting a user can pick — including a "System" option that follows the phone — and you want it to be remembered and to theme the whole app, tab bar included.
Prerequisites
- Expo SDK 57, expo-router
- NativeWind v3 (Tailwind v3 under the hood)
- Redux Toolkit for the in-app choice
-
@react-native-async-storage/async-storagefor persistence
The shape, before any steps
palette (one object) → tailwind.config dark stops → bg-page dark:bg-page-dark
user picks a mode → Redux prefsSlice.theme (in-app source of truth)
→ colorScheme.set(pref) (drives NativeWind + RN Appearance)
→ AsyncStorage (persist across launches)
on launch → load pref → dispatch + apply (restore, no flash)
Five moving parts, and exactly one of them (colorScheme.set) is doing the heavy lifting.
Step 1 — one palette, dark stops in Tailwind
Define colors once and give each a dark counterpart in tailwind.config, so a token becomes a pair. This keeps "what colour is a card" in a single place instead of scattered across components.
// tailwind.config.ts
import { palette } from "./src/constants/design-tokens";
export default {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
page: { DEFAULT: palette.neutral.background, dark: palette.dark.background },
surface: { DEFAULT: palette.neutral.surface, dark: palette.dark.surface },
ink: { DEFAULT: palette.neutral.textPrimary, inverse: palette.dark.textPrimary },
brand: { DEFAULT: palette.brand.primary, bright: palette.dark.primary /* teal on dark */ },
// …line, coral, semantic trio, each with its dark stop
},
},
},
} satisfies Config;
Now every surface is a class pair. You write the light token and its dark stop together, and the dark: variant does the switch:
<View className="flex-1 bg-page p-5 dark:bg-page-dark">
<Text className="text-ink dark:text-ink-inverse">Weekly shop</Text>
</View>
One RN gotcha worth stealing: React Native can't synthesize font weights, so each weight is a separate loaded font file and its own family (
font-nunito,font-nunito-bold, …) — notfont-bold. Unrelated to theming, but it lives in the same config and bites everyone once.
Step 2 — the one call that themes everything
Here's the whole engine. NativeWind exposes an imperative colorScheme handle; setting it to "light", "dark", or "system" is the entire apply step:
// lib/theme-mode.ts
import { colorScheme } from "nativewind";
export function applyThemePref(pref: ThemePref): void {
colorScheme.set(pref); // "light" | "dark" | "system"
}
The reason this is the whole engine — and not just half of it — is what colorScheme.set touches. It drives NativeWind's dark: variants and React Native's Appearance API. So anything reading useColorScheme() updates too: expo-router's ThemeProvider, and with it your navigation header and the native tab bar. You theme your own views with dark: classes; the framework chrome comes along for free because it's reading the same signal.
// app/_layout.tsx — nav + tab bar follow useColorScheme(), which colorScheme.set() feeds
const colorScheme = useColorScheme();
// …
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<AuthGate><AppTabs /></AuthGate>
</ThemeProvider>
That's the payoff of the whole post: you don't theme each surface separately. One call, and the utility classes, the nav theme, and the tab bar all move together. Here's a second, entirely different screen — the More tab — proving it: same setting, and the list rows, header, and tab bar all follow.
Step 3 — a source of truth in Redux
colorScheme.set() applies a mode, but your UI still needs to know the current choice — to check the selected row in the picker, for instance. That's client state, so it goes in Redux (server data belongs to React Query — never mix the two):
// store/slices/prefsSlice.ts
export type ThemePref = "system" | "light" | "dark";
const initialState = { theme: "system" as ThemePref };
export const prefsSlice = createSlice({
name: "prefs",
initialState,
reducers: {
setThemePref: (state, action: PayloadAction<ThemePref>) => {
state.theme = action.payload;
},
},
});
Step 4 — persist it to AsyncStorage
A setting that forgets itself on relaunch isn't a setting. Load and save the pref under one key, and keep both operations non-fatal — a storage hiccup should never crash the app or block the theme from applying for the current session:
// lib/theme-mode.ts
const THEME_KEY = "cannycart:themePref";
export async function loadThemePref(): Promise<ThemePref> {
try {
const stored = await AsyncStorage.getItem(THEME_KEY);
if (stored === "light" || stored === "dark" || stored === "system") return stored;
} catch {
// fall through to default
}
return "system"; // sensible default: follow the phone
}
export async function saveThemePref(pref: ThemePref): Promise<void> {
try {
await AsyncStorage.setItem(THEME_KEY, pref);
} catch {
// non-fatal: the pref still applies for this session
}
}
Step 5 — the Appearance picker
Now the UI. Three rows; selecting one does three things in order — record it in Redux, apply it, and persist it (fire-and-forget, because the apply shouldn't wait on disk):
// app/more/appearance.tsx
function select(pref: ThemePref) {
dispatch(setThemePref(pref)); // 1. source of truth
applyThemePref(pref); // 2. colorScheme.set — themes the whole app
saveThemePref(pref); // 3. persist, fire-and-forget
}
The rows themselves are just class pairs, with the selected one tinted and checked:
<Pressable
onPress={() => select(option.value)}
className={`flex-row items-center gap-3 px-4 py-4
${selected ? "bg-brand-mint dark:bg-brand-mint-dark" : ""}`}
>
<Text className="text-ink dark:text-ink-inverse">{option.label}</Text>
{selected ? <Iconify icon="mdi:check-circle" size={22} color={brand} /> : null}
</Pressable>
Step 6 — restore on launch, without a flash
Persistence is only half of "remembered." On the next launch you have to read the pref back and apply it before the app paints, or the user sees a flash of the wrong theme. A tiny component does it — it renders nothing, sits inside the Redux provider, and applies the stored pref on mount:
// app/_layout.tsx
function ThemeModeInit() {
const dispatch = useAppDispatch();
useEffect(() => {
loadThemePref().then((pref) => {
dispatch(setThemePref(pref)); // sync Redux
applyThemePref(pref); // and the actual theme
});
}, [dispatch]);
return null;
}
// …mounted first thing inside <ReduxProvider>, above the rest of the tree:
<ReduxProvider store={store}>
<ThemeModeInit />
{/* QueryClient → SafeArea → ThemeProvider → tabs … */}
</ReduxProvider>
The native splash is already held while fonts load (SplashScreen.preventAutoHideAsync()), which gives the async read a beat to resolve before the first real frame — so System-in-dark comes up dark, not a white flash then dark.
Verify it works
Four checks, in order:
- System follows the phone. Pick System, then flip your device to Dark in Control Center / Quick Settings — the app should switch live.
- Light/Dark override. Pick Dark on a light phone (and vice versa) — it should hold regardless of the device.
- It persists. Fully kill and relaunch — your last choice should come back with no flash.
-
The chrome follows. In dark mode, confirm the tab bar and the navigation header are dark too — that's the
colorScheme.set→Appearancelink doing its job. If your own views go dark but the tab bar stays light, you themed withdark:but never calledcolorScheme.set.
Where to go next
Two threads from here. First, per-user hygiene: this key is global, so on a shared device one account's theme would greet the next. Clearing user-specific AsyncStorage keys on sign-in/out (the user-prefs-cache-setup pattern) closes that. Second, the web app is still on light-only — the same palette already feeds its Tailwind config, so the port is mostly mode plumbing, not redesign.
Which surface in your app was the last to get the theming memo? For me it's always a hard-coded #fff on a shadow or a border, smug and glowing in dark mode. Tell me yours.




Top comments (0)