DEV Community

Cover image for React Native Dark Mode: Zustand vs Redux Toolkit Guide
PEAKIQ
PEAKIQ

Posted on • Originally published at peakiq.in

React Native Dark Mode: Zustand vs Redux Toolkit Guide

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

  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,
};
Enter fullscreen mode Exit fullscreen mode
// 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,
};
Enter fullscreen mode Exit fullscreen mode
// 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,
};
Enter fullscreen mode Exit fullscreen mode

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 }),
    }
  )
);
Enter fullscreen mode Exit fullscreen 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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode
// In your root component (Zustand version)
export default function App() {
  useSystemThemeSync();  // call once here, never in children
  return <RootNavigator />;
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conditional Images

import { useIsDark } from '../store/themeSelectors';  // or useTheme().dark

const logoSource = useIsDark()
  ? require('../assets/logo-dark.png')
  : require('../assets/logo-light.png');
Enter fullscreen mode Exit fullscreen mode

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'} />
      {/* ... */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 }),
      ])
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)