Originally published on PEAKIQ
Source: https://www.peakiq.in/blog/how-to-handle-dark-mode-in-react-native-with-zustand-and-redux-toolkit
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 only
Pitfall 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.
Top comments (0)