Peakiq Blog
How to Handle Dark Mode in React Native with Zustand and Redux Toolkit
Master React Native dark mode with this guide. Compare Zustand and Redux Toolkit for theme state, implement persistence, sync with OS, animate switches, and
How Production Apps Handle Dark Mode in React Native with Zustand and Redux Toolkit
A deep-dive into theme architecture, persistence, system sync, and performance patterns used in real production codebases.
Table of Contents
- The Core Problem
- Theme Token Architecture
- Approach A: Zustand for Theme State
- Approach B: Redux Toolkit for Theme State
- Persisting Theme Preference
- Syncing with the System Color Scheme
- Providing Theme via Context
- Consuming Theme in Components
- Animated Transitions Between Themes
- Handling Images, Icons, and Assets
- Testing Dark Mode
- Performance Pitfalls and Fixes
- Zustand vs Redux Toolkit: Which to Use
The Core Problem
Dark mode is deceptively simple on the surface — swap some colors and you're done. In practice, production apps deal with:
- User preference that must survive app restarts
- System-level sync (following OS dark/light setting)
- Per-screen or per-component overrides (e.g., a modal that is always dark)
- Animated transitions to avoid jarring flashes
- Consistent token names so designers and developers share a vocabulary
- SSR/hydration (if using Expo with web target)
Both Zustand and Redux Toolkit are excellent choices — they just have different trade-offs at scale.
Theme Token Architecture
Before wiring up any state manager, define your design tokens. This is the foundation everything else builds on.
// theme/tokens.ts
export interface ColorTokens {
// Backgrounds
bgPrimary: string;
bgSecondary: string;
bgElevated: string;
// Text
textPrimary: string;
textSecondary: string;
textDisabled: string;
// Interactive
accent: string;
accentPressed: string;
destructive: string;
// Borders
borderDefault: string;
borderStrong: string;
// Status
success: string;
warning: string;
error: string;
}
export interface Theme {
dark: boolean;
colors: ColorTokens;
spacing: typeof spacing;
typography: typeof typography;
}
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
} as const;
export const typography = {
fontSizeXs: 11,
fontSizeSm: 13,
fontSizeMd: 15,
fontSizeLg: 17,
fontSizeXl: 20,
fontSizeXxl: 28,
fontWeightRegular: '400' as const,
fontWeightMedium: '500' as const,
fontWeightBold: '700' as const,
lineHeightBody: 22,
lineHeightHeading: 32,
};// theme/lightTheme.ts
import { Theme } from './tokens';
import { spacing, typography } from './tokens';
export const lightTheme: Theme = {
dark: false,
colors: {
bgPrimary: '#FFFFFF',
bgSecondary: '#F5F5F7',
bgElevated: '#FFFFFF',
textPrimary: '#1C1C1E',
textSecondary:'#6E6E73',
textDisabled: '#AEAEB2',
accent: '#007AFF',
accentPressed:'#0062CC',
destructive: '#FF3B30',
borderDefault:'#E5E5EA',
borderStrong: '#C7C7CC',
success: '#34C759',
warning: '#FF9500',
error: '#FF3B30',
},
spacing,
typography,
};// theme/darkTheme.ts
import { Theme } from './tokens';
import { spacing, typography } from './tokens';
export const darkTheme: Theme = {
dark: true,
colors: {
bgPrimary: '#000000',
bgSecondary: '#1C1C1E',
bgElevated: '#2C2C2E',
textPrimary: '#FFFFFF',
textSecondary:'#8E8E93',
textDisabled: '#48484A',
accent: '#0A84FF',
accentPressed:'#0070E0',
destructive: '#FF453A',
borderDefault:'#38383A',
borderStrong: '#48484A',
success: '#30D158',
warning: '#FF9F0A',
error: '#FF453A',
},
spacing,
typography,
};Production tip: Never hardcode
#000or#fffinside components. Always referencetheme.colors.textPrimary. This is the single most important discipline for maintainable theming.
Approach A: Zustand for Theme State
Zustand shines here because theme state is global but simple — no reducers, no boilerplate.
Store Definition
// store/themeStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { lightTheme, darkTheme } from '../theme';
import { Theme } from '../theme/tokens';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeState {
mode: ThemeMode;
resolvedTheme: Theme;
setMode: (mode: ThemeMode) => void;
_resolveTheme: (systemIsDark: boolean) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
mode: 'system',
resolvedTheme: lightTheme,
setMode: (mode) => {
set({ mode });
// Callers should also invoke _resolveTheme with current system value
},
_resolveTheme: (systemIsDark: boolean) => {
const { mode } = get();
let resolved: Theme;
if (mode === 'system') {
resolved = systemIsDark ? darkTheme : lightTheme;
} else {
resolved = mode === 'dark' ? darkTheme : lightTheme;
}
set({ resolvedTheme: resolved });
},
}),
{
name: 'theme-storage',
storage: createJSONStorage(() => AsyncStorage),
// Only persist the user's mode choice, not the full resolved theme
partialize: (state) => ({ mode: state.mode }),
}
)
);Selector Hooks
// store/themeSelectors.ts
import { useThemeStore } from './themeStore';
// Fine-grained selectors prevent unnecessary re-renders
export const useThemeMode = () => useThemeStore((s) => s.mode);
export const useResolvedTheme = () => useThemeStore((s) => s.resolvedTheme);
export const useThemeColors = () => useThemeStore((s) => s.resolvedTheme.colors);
export const useIsDark = () => useThemeStore((s) => s.resolvedTheme.dark);
export const useSetThemeMode = () => useThemeStore((s) => s.setMode);Approach B: Redux Toolkit for Theme State
RTK is the right call when you need time-travel debugging, middleware (analytics, logging), or your theme decision needs to sit alongside other complex global state.
Slice
// store/themeSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { lightTheme, darkTheme } from '../theme';
import { Theme } from '../theme/tokens';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeSliceState {
mode: ThemeMode;
resolvedTheme: Theme;
}
const initialState: ThemeSliceState = {
mode: 'system',
resolvedTheme: lightTheme,
};
export const themeSlice = createSlice({
name: 'theme',
initialState,
reducers: {
setThemeMode(state, action: PayloadAction<ThemeMode>) {
state.mode = action.payload;
},
resolveTheme(state, action: PayloadAction<{ systemIsDark: boolean }>) {
const { systemIsDark } = action.payload;
if (state.mode === 'system') {
state.resolvedTheme = systemIsDark ? darkTheme : lightTheme;
} else {
state.resolvedTheme = state.mode === 'dark' ? darkTheme : lightTheme;
}
},
},
});
export const { setThemeMode, resolveTheme } = themeSlice.actions;
export default themeSlice.reducer;Selectors
// store/themeSelectors.ts
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from './store';
const selectThemeState = (state: RootState) => state.theme;
export const selectThemeMode = createSelector(selectThemeState, (t) => t.mode);
export const selectResolvedTheme = createSelector(selectThemeState, (t) => t.resolvedTheme);
export const selectThemeColors = createSelector(selectThemeState, (t) => t.resolvedTheme.colors);
export const selectIsDark = createSelector(selectThemeState, (t) => t.resolvedTheme.dark);Typed Hooks
// store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;Redux Persistence with redux-persist
// store/store.ts
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import {
persistStore,
persistReducer,
FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER,
} from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
import themeReducer from './themeSlice';
const themePersistConfig = {
key: 'theme',
storage: AsyncStorage,
// Only persist the mode, not the full resolved theme object
whitelist: ['mode'],
};
const rootReducer = combineReducers({
theme: persistReducer(themePersistConfig, themeReducer),
// ...other reducers
});
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;Persisting Theme Preference
The key insight: persist only the user's mode choice ('light' | 'dark' | 'system'), not the resolved theme object. The resolved theme is derived state — recompute it on launch.
Hydration Pattern (RTK)
// App.tsx
import React, { useEffect } from 'react';
import { useColorScheme } from 'react-native';
import { PersistGate } from 'redux-persist/integration/react';
import { Provider } from 'react-redux';
import { store, persistor } from './store/store';
import { resolveTheme } from './store/themeSlice';
import { useAppDispatch, useAppSelector } from './store/hooks';
import { selectThemeMode } from './store/themeSelectors';
import { ThemeProvider } from './theme/ThemeProvider';
import RootNavigator from './navigation/RootNavigator';
function AppInner() {
const dispatch = useAppDispatch();
const systemScheme = useColorScheme();
const mode = useAppSelector(selectThemeMode);
useEffect(() => {
// Re-resolve whenever system scheme or mode changes
dispatch(resolveTheme({ systemIsDark: systemScheme === 'dark' }));
}, [systemScheme, mode, dispatch]);
return (
<ThemeProvider>
<RootNavigator />
</ThemeProvider>
);
}
export default function App() {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<AppInner />
</PersistGate>
</Provider>
);
}Syncing with the System Color Scheme
React Native's useColorScheme hook is the bridge to the OS preference. Call it in one place only — your root component — and dispatch/set store state from there.
// hooks/useSystemThemeSync.ts (Zustand version)
import { useEffect } from 'react';
import { useColorScheme } from 'react-native';
import { useThemeStore } from '../store/themeStore';
export function useSystemThemeSync() {
const systemScheme = useColorScheme();
const resolveTheme = useThemeStore((s) => s._resolveTheme);
const mode = useThemeStore((s) => s.mode);
useEffect(() => {
resolveTheme(systemScheme === 'dark');
}, [systemScheme, mode, resolveTheme]);
}// In your root component (Zustand version)
export default function App() {
useSystemThemeSync(); // call once here, never in children
return <RootNavigator />;
}Important: On Android,
useColorSchemecan returnnullon first render. Always default to'light'when null to avoid a flash.
const systemIsDark = (useColorScheme() ?? 'light') === 'dark';Providing Theme via Context
Even with a global store, a React Context layer is the right way to deliver the resolved theme to components. This decouples component code from your state manager choice.
// theme/ThemeContext.tsx
import React, { createContext, useContext } from 'react';
import { Theme } from './tokens';
import { lightTheme } from './lightTheme';
const ThemeContext = createContext<Theme>(lightTheme);
export const useTheme = () => useContext(ThemeContext);// theme/ThemeProvider.tsx (RTK version)
import React from 'react';
import { ThemeContext } from './ThemeContext';
import { useAppSelector } from '../store/hooks';
import { selectResolvedTheme } from '../store/themeSelectors';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const resolvedTheme = useAppSelector(selectResolvedTheme);
return (
<ThemeContext.Provider value={resolvedTheme}>
{children}
</ThemeContext.Provider>
);
}// theme/ThemeProvider.tsx (Zustand version)
import React from 'react';
import { ThemeContext } from './ThemeContext';
import { useResolvedTheme } from '../store/themeSelectors';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const resolvedTheme = useResolvedTheme();
return (
<ThemeContext.Provider value={resolvedTheme}>
{children}
</ThemeContext.Provider>
);
}Both look identical from the component's perspective — a clean separation.
Consuming Theme in Components
// components/Card.tsx
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { useTheme } from '../theme/ThemeContext';
interface CardProps {
title: string;
subtitle?: string;
style?: ViewStyle;
}
export function Card({ title, subtitle, style }: CardProps) {
const { colors, spacing, typography } = useTheme();
const styles = StyleSheet.create({
container: {
backgroundColor: colors.bgElevated,
borderRadius: 12,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.borderDefault,
},
title: {
fontSize: typography.fontSizeLg,
fontWeight: typography.fontWeightBold,
color: colors.textPrimary,
marginBottom: spacing.xs,
},
subtitle: {
fontSize: typography.fontSizeSm,
color: colors.textSecondary,
lineHeight: typography.lineHeightBody,
},
});
return (
<View style={[styles.container, style]}>
<Text style={styles.title}>{title}</Text>
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
</View>
);
}Performance: useMemo for Derived Styles
For components with many styles, memoize the StyleSheet to avoid recreating it on every render:
import React, { useMemo } from 'react';
import { StyleSheet } from 'react-native';
import { useTheme } from '../theme/ThemeContext';
export function ExpensiveList() {
const { colors, spacing } = useTheme();
const styles = useMemo(
() =>
StyleSheet.create({
item: {
backgroundColor: colors.bgSecondary,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderDefault,
},
label: {
color: colors.textPrimary,
fontSize: 15,
},
}),
[colors, spacing]
);
// ...render
}Animated Transitions Between Themes
Without animation, switching themes causes a jarring flash. Use Animated or Reanimated 2 for a smooth crossfade.
// theme/ThemedBackground.tsx
import React, { useEffect, useRef } from 'react';
import { Animated, StyleSheet, ViewProps } from 'react-native';
import { useTheme } from './ThemeContext';
export function ThemedBackground({ children, style, ...props }: ViewProps) {
const { colors, dark } = useTheme();
const animatedColor = useRef(new Animated.Value(dark ? 1 : 0)).current;
useEffect(() => {
Animated.timing(animatedColor, {
toValue: dark ? 1 : 0,
duration: 300,
useNativeDriver: false, // background color can't use native driver
}).start();
}, [dark, animatedColor]);
const backgroundColor = animatedColor.interpolate({
inputRange: [0, 1],
outputRange: ['#FFFFFF', '#000000'],
});
return (
<Animated.View
style={[styles.fill, { backgroundColor }, style]}
{...props}
>
{children}
</Animated.View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
});Reanimated 2 alternative: Use
useSharedValue+useAnimatedStylewithwithTimingfor better performance on the UI thread. The Animated API above works fine for most cases.
Handling Images, Icons, and Assets
SVG Icons
Use react-native-svg and pass colors as props from the theme:
// components/icons/HomeIcon.tsx
import React from 'react';
import Svg, { Path } from 'react-native-svg';
import { useTheme } from '../../theme/ThemeContext';
interface HomeIconProps {
size?: number;
color?: string; // allow override
}
export function HomeIcon({ size = 24, color }: HomeIconProps) {
const { colors } = useTheme();
const fill = color ?? colors.textPrimary;
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M3 9.5L12 3l9 6.5V20a1 1 0 01-1 1H4a1 1 0 01-1-1V9.5z"
fill={fill}
/>
</Svg>
);
}Conditional Images
import { useIsDark } from '../store/themeSelectors'; // or useTheme().dark
const logoSource = useIsDark()
? require('../assets/logo-dark.png')
: require('../assets/logo-light.png');Status Bar
import { StatusBar } from 'expo-status-bar';
// or: import { StatusBar } from 'react-native';
function RootLayout() {
const isDark = useTheme().dark;
return (
<>
<StatusBar style={isDark ? 'light' : 'dark'} />
{/* ... */}
</>
);
}Testing Dark Mode
Unit Tests (Jest)
// __tests__/Card.test.tsx
import React from 'react';
import { render } from '@testing-library/react-native';
import { ThemeContext } from '../theme/ThemeContext';
import { darkTheme, lightTheme } from '../theme';
import { Card } from '../components/Card';
function renderWithTheme(ui: React.ReactElement, theme = lightTheme) {
return render(
<ThemeContext.Provider value={theme}>{ui}</ThemeContext.Provider>
);
}
describe('Card', () => {
it('renders with light theme', () => {
const { getByText } = renderWithTheme(<Card title="Hello" />, lightTheme);
expect(getByText('Hello')).toBeTruthy();
});
it('renders with dark theme', () => {
const { getByText } = renderWithTheme(<Card title="Hello" />, darkTheme);
expect(getByText('Hello')).toBeTruthy();
});
it('uses dark background color in dark mode', () => {
const { getByTestId } = renderWithTheme(
<Card title="Hello" testID="card" />,
darkTheme
);
const card = getByTestId('card');
expect(card.props.style).toMatchObject(
expect.arrayContaining([
expect.objectContaining({ backgroundColor: darkTheme.colors.bgElevated }),
])
);
});
});E2E Tests (Detox)
// e2e/darkMode.test.ts
describe('Dark Mode', () => {
it('should switch to dark mode and persist on relaunch', async () => {
await device.launchApp();
await element(by.id('settings-tab')).tap();
await element(by.id('dark-mode-toggle')).tap();
// Relaunch without clearing state
await device.launchApp({ newInstance: true });
await element(by.id('settings-tab')).tap();
await expect(element(by.id('dark-mode-toggle'))).toHaveToggleValue(true);
});
});Performance Pitfalls and Fixes
Pitfall 1: Creating StyleSheet inside render
// ❌ Bad — new StyleSheet object every render
function BadComponent() {
const { colors } = useTheme();
return (
<View style={StyleSheet.create({ container: { backgroundColor: colors.bgPrimary } }).container} />
);
}
// ✅ Good — memoized
function GoodComponent() {
const { colors } = useTheme();
const styles = useMemo(
() => StyleSheet.create({ container: { backgroundColor: colors.bgPrimary } }),
[colors.bgPrimary]
);
return <View style={styles.container} />;
}Pitfall 2: Subscribing to entire theme in leaf components
// ❌ Re-renders whenever ANY part of theme changes
const theme = useTheme();
const bg = theme.colors.bgPrimary;
// ✅ Fine in practice — context re-renders only when resolvedTheme object changes
// (which only happens on mode change), so this is acceptable.
// For Zustand: use fine-grained selectors.
const { bgPrimary } = useThemeColors(); // selector returns colors object onlyPitfall 3: Not memoizing ThemeProvider's value
// ❌ New object reference on every parent render
<ThemeContext.Provider value={{ ...resolvedTheme }}>
// ✅ The resolvedTheme from the store is already a stable reference
// (same object until mode changes), so pass it directly:
<ThemeContext.Provider value={resolvedTheme}>Pitfall 4: Forgetting the NavigationContainer theme
import { NavigationContainer, DarkTheme, DefaultTheme } from '@react-navigation/native';
import { useTheme } from '../theme/ThemeContext';
function Navigation() {
const { dark } = useTheme();
return (
<NavigationContainer theme={dark ? DarkTheme : DefaultTheme}>
{/* screens */}
</NavigationContainer>
);
}Zustand vs Redux Toolkit: Which to Use
| Concern | Zustand | Redux Toolkit |
|---|---|---|
| Bundle size | ~1 KB | ~12 KB (with immer, reselect) |
| Boilerplate | Minimal | Moderate (slice + selectors + hooks) |
| DevTools | via middleware | Built-in Redux DevTools |
| Middleware ecosystem | Custom | Rich (thunk, saga, logger) |
| Time-travel debugging | No | Yes |
| Ideal team size | Small–medium | Medium–large |
| When theme pairs with complex async state | Awkward | Natural |
| Learning curve | Very low | Low–medium |
The Hybrid Pattern
Many production apps use both: Zustand for lightweight UI state (theme, modals, toasts) and RTK for domain state (auth, user data, API cache).
// This is completely valid and common in production
import { useThemeColors } from '../store/themeStore'; // Zustand
import { useAppSelector } from '../store/hooks'; // RTK
import { selectCurrentUser } from '../store/userSlice'; // RTK
function ProfileScreen() {
const colors = useThemeColors(); // Zustand
const user = useAppSelector(selectCurrentUser); // RTK
// ...
}Summary
The architecture that holds up in production:
- Define tokens first —
ColorTokens,Themeinterface, light/dark objects - Store only the mode (
'light' | 'dark' | 'system') in AsyncStorage - Derive the resolved theme from mode + system preference, recompute on change
- Bridge via React Context so components are agnostic of your state manager
- Fine-grained selectors to prevent unnecessary re-renders
- Animate transitions with
Animated.timingor Reanimated 2 - Propagate to NavigationContainer and StatusBar — don't forget them
Whether you pick Zustand or RTK, the component-level API is identical. Swap state managers without touching a single component.