PEAKIQ - Software Solutions & Digital Innovation Peakiq Software Development

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

Editorial17 min read3254 words
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

  1. The Core Problem
  2. Theme Token Architecture
  3. Approach A: Zustand for Theme State
  4. Approach B: Redux Toolkit for Theme State
  5. Persisting Theme Preference
  6. Syncing with the System Color Scheme
  7. Providing Theme via Context
  8. Consuming Theme in Components
  9. Animated Transitions Between Themes
  10. Handling Images, Icons, and Assets
  11. Testing Dark Mode
  12. Performance Pitfalls and Fixes
  13. 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 #000 or #fff inside components. Always reference theme.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, useColorScheme can return null on 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 + useAnimatedStyle with withTiming for 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

ConcernZustandRedux Toolkit
Bundle size~1 KB~12 KB (with immer, reselect)
BoilerplateMinimalModerate (slice + selectors + hooks)
DevToolsvia middlewareBuilt-in Redux DevTools
Middleware ecosystemCustomRich (thunk, saga, logger)
Time-travel debuggingNoYes
Ideal team sizeSmall–mediumMedium–large
When theme pairs with complex async stateAwkwardNatural
Learning curveVery lowLow–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:

  1. Define tokens firstColorTokens, Theme interface, light/dark objects
  2. Store only the mode ('light' | 'dark' | 'system') in AsyncStorage
  3. Derive the resolved theme from mode + system preference, recompute on change
  4. Bridge via React Context so components are agnostic of your state manager
  5. Fine-grained selectors to prevent unnecessary re-renders
  6. Animate transitions with Animated.timing or Reanimated 2
  7. 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.