g: { xs: number; sm: number; md: number; lg: number; xl: number };
typography: { body: number; heading: number; caption: number };
}
export const lightPalette: PaletteSet = {
isDark: false,
colors: {
surfacePrimary: '#F8F9FA',
surfaceSecondary: '#FFFFFF',
surfaceOverlay: 'rgba(255, 255, 255, 0.95)',
textPrimary: '#111827',
textMuted: '#6B7280',
textDisabled: '#9CA3AF',
interactiveBase: '#2563EB',
interactiveActive: '#1D4ED8',
feedbackPositive: '#059669',
feedbackNegative: '#DC2626',
borderSubtle: '#E5E7EB',
borderStrong: '#D1D5DB',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
typography: { body: 15, heading: 22, caption: 12 },
};
export const darkPalette: PaletteSet = {
isDark: true,
colors: {
surfacePrimary: '#0F172A',
surfaceSecondary: '#1E293B',
surfaceOverlay: 'rgba(15, 23, 42, 0.95)',
textPrimary: '#F1F5F9',
textMuted: '#94A3B8',
textDisabled: '#64748B',
interactiveBase: '#3B82F6',
interactiveActive: '#60A5FA',
feedbackPositive: '#10B981',
feedbackNegative: '#EF4444',
borderSubtle: '#334155',
borderStrong: '#475569',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
typography: { body: 15, heading: 22, caption: 12 },
};
**Architecture Rationale:** Tokens are exported as plain objects. This guarantees immutability at the module level and allows tree-shaking. Components never import hex values directly; they consume the resolved palette through selectors.
### Step 2: Implement Derived State Architecture
The user's preference is the only piece of state that requires persistence. The actual palette is computed at runtime based on the preference and the current OS signal.
#### Zustand Implementation
```typescript
// state/theme-engine.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { lightPalette, darkPalette, PaletteSet } from '../design-system/palette';
type PreferenceMode = 'light' | 'dark' | 'system';
interface ThemeEngineState {
preference: PreferenceMode;
activePalette: PaletteSet;
updatePreference: (mode: PreferenceMode) => void;
syncWithOS: (osIsDark: boolean) => void;
}
export const useThemeEngine = create<ThemeEngineState>()(
persist(
(set, get) => ({
preference: 'system',
activePalette: lightPalette,
updatePreference: (mode) => {
set({ preference: mode });
},
syncWithOS: (osIsDark) => {
const { preference } = get();
const targetPalette =
preference === 'system'
? (osIsDark ? darkPalette : lightPalette)
: (preference === 'dark' ? darkPalette : lightPalette);
set({ activePalette: targetPalette });
},
}),
{
name: 'app-theme-pref',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({ preference: state.preference }),
}
)
);
Redux Toolkit Implementation
// state/theme-slice.ts
import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit';
import { lightPalette, darkPalette, PaletteSet } from '../design-system/palette';
type PreferenceMode = 'light' | 'dark' | 'system';
interface ThemeSliceState {
preference: PreferenceMode;
activePalette: PaletteSet;
}
const initialThemeState: ThemeSliceState = {
preference: 'system',
activePalette: lightPalette,
};
export const themeSlice = createSlice({
name: 'theme',
initialState: initialThemeState,
reducers: {
setPreference: (state, action: PayloadAction<PreferenceMode>) => {
state.preference = action.payload;
},
resolvePalette: (state, action: PayloadAction<{ osIsDark: boolean }>) => {
const { osIsDark } = action.payload;
state.activePalette =
state.preference === 'system'
? (osIsDark ? darkPalette : lightPalette)
: (state.preference === 'dark' ? darkPalette : lightPalette);
},
},
});
export const { setPreference, resolvePalette } = themeSlice.actions;
// Memoized selectors
const selectThemeDomain = (root: { theme: ThemeSliceState }) => root.theme;
export const selectPreference = createSelector(
selectThemeDomain,
(domain) => domain.preference
);
export const selectActivePalette = createSelector(
selectThemeDomain,
(domain) => domain.activePalette
);
export default themeSlice.reducer;
Architecture Rationale: Both implementations follow the derived-state pattern. The activePalette is never persisted. It is recomputed whenever the preference changes or the OS signal updates. This eliminates state bloat and ensures hydration always produces the correct palette based on current conditions.
Step 3: Synchronize with Operating System Signals
React Native exposes useColorScheme to detect OS-level preferences. This hook must be wired into the state manager during application initialization.
// app/theme-provider.tsx
import { useEffect } from 'react';
import { useColorScheme } from 'react-native';
import { useThemeEngine } from '../state/theme-engine';
import { useAppDispatch } from '../state/hooks';
import { resolvePalette } from '../state/theme-slice';
import { selectPreference } from '../state/theme-slice';
import { useAppSelector } from '../state/hooks';
export function ThemeSyncBridge() {
const osScheme = useColorScheme();
const isOsDark = osScheme === 'dark';
// Zustand path
const syncZustand = useThemeEngine((s) => s.syncWithOS);
useEffect(() => {
syncZustand(isOsDark);
}, [isOsDark, syncZustand]);
// RTK path (uncomment if using Redux)
/*
const dispatch = useAppDispatch();
const pref = useAppSelector(selectPreference);
useEffect(() => {
dispatch(resolvePalette({ osIsDark: isOsDark }));
}, [isOsDark, pref, dispatch]);
*/
return null;
}
Architecture Rationale: The sync bridge runs once at the root level. It listens to useColorScheme and triggers a state update only when the OS signal changes. By isolating this logic, component trees remain decoupled from platform-specific APIs.
Step 4: Build Consumption Hooks
Components should never access the state manager directly. Custom hooks provide a stable interface and enable fine-grained subscriptions.
// hooks/use-app-theme.ts
import { useThemeEngine } from '../state/theme-engine';
import { useAppSelector } from '../state/hooks';
import { selectActivePalette } from '../state/theme-slice';
// Zustand selector pattern
export const usePalette = () => useThemeEngine((s) => s.activePalette);
export const useIsDarkMode = () => useThemeEngine((s) => s.activePalette.isDark);
export const useSetThemePreference = () => useThemeEngine((s) => s.updatePreference);
// RTK selector pattern (alternative)
export const usePaletteRTK = () => useAppSelector(selectActivePalette);
Architecture Rationale: Selector functions extract only the required slice of state. React's reconciliation algorithm skips components whose selected values haven't changed. This prevents full-tree re-renders when theme state updates.
Pitfall Guide
1. Storing Resolved Themes in Global State
Explanation: Persisting the complete palette object inflates AsyncStorage payloads and forces unnecessary serialization/deserialization cycles.
Fix: Persist only the user's preference ('light' | 'dark' | 'system'). Recompute the palette on hydration using static token constants.
Explanation: redux-persist dispatches non-serializable actions (FLUSH, REHYDRATE, etc.) that trigger RTK's default middleware warnings, cluttering development logs and potentially breaking strict mode.
Fix: Configure configureStore to ignore persist lifecycle actions:
middleware: (getDefault) => getDefault({
serializableCheck: {
ignoredActions: ['FLUSH', 'REHYDRATE', 'PAUSE', 'PERSIST', 'PURGE', 'REGISTER'],
},
})
3. Hardcoding Hex Values in Components
Explanation: Direct color references bypass the theme system, creating maintenance debt and breaking dark mode consistency.
Fix: Enforce a linting rule or code review standard that requires all colors to flow through theme.colors.* or equivalent selectors.
4. Missing System Scheme Listeners
Explanation: Applications that only toggle theme on user interaction fail to adapt when users change OS settings while the app is in the background.
Fix: Always pair useColorScheme with a root-level effect that updates the state manager. Handle both initial mount and subsequent changes.
5. Over-Subscribing to Theme Context
Explanation: Wrapping large component trees in a single theme context causes unnecessary re-renders when any part of the theme updates.
Fix: Use selector-based hooks instead of raw context consumption. Split theme context into granular providers if necessary (e.g., ColorProvider, TypographyProvider).
6. Hydration Flash on Web/Expo Targets
Explanation: SSR or static export environments render with default light theme before client-side hydration applies the persisted preference, causing a visible flash.
Fix: Inject a script tag in the HTML head that reads localStorage or AsyncStorage and applies a data-theme attribute before React mounts. For Expo, use expo-splash-screen with theme-aware configuration.
7. Neglecting Memoization in List Items
Explanation: FlatList or SectionList items that consume theme selectors re-render on every theme change, causing scroll jank.
Fix: Wrap list items in React.memo and ensure theme selectors return stable references. Consider caching resolved styles outside the render cycle.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| New project with minimal global state | Zustand | Zero boilerplate, built-in persistence, smaller bundle | Low |
| Existing Redux codebase with complex middleware | Redux Toolkit | Maintains ecosystem consistency, enables time-travel debugging | Medium (setup overhead) |
| Multi-platform app requiring SSR/web parity | Zustand + use-sync-external-store | Simpler hydration path, easier to adapt to React 18 streaming | Low |
| Enterprise app with strict state audit requirements | Redux Toolkit | Middleware pipeline supports logging, analytics, and state snapshots | High |
| Performance-critical list-heavy UI | Zustand with fine-grained selectors | Lower reconciliation overhead, easier to optimize with React.memo | Low |
Configuration Template
Zustand Store Setup
// store/theme-store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { lightPalette, darkPalette } from '../design-system/palette';
export const useThemeStore = create(
persist(
(set, get) => ({
mode: 'system' as 'light' | 'dark' | 'system',
palette: lightPalette,
setMode: (mode: 'light' | 'dark' | 'system') => set({ mode }),
sync: (isDark: boolean) => {
const { mode } = get();
const resolved = mode === 'system' ? (isDark ? darkPalette : lightPalette) : (mode === 'dark' ? darkPalette : lightPalette);
set({ palette: resolved });
},
}),
{
name: 'theme-config',
storage: createJSONStorage(() => AsyncStorage),
partialize: (s) => ({ mode: s.mode }),
}
)
);
Redux Toolkit Store Setup
// store/root-store.ts
import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
import themeReducer from './theme-slice';
const persistConfig = {
key: 'theme',
storage: AsyncStorage,
whitelist: ['preference'],
};
export const store = configureStore({
reducer: {
theme: persistReducer(persistConfig, themeReducer),
},
middleware: (getDefault) =>
getDefault({
serializableCheck: {
ignoredActions: ['FLUSH', 'REHYDRATE', 'PAUSE', 'PERSIST', 'PURGE', 'REGISTER'],
},
}),
});
export const persistor = persistStore(store);
Quick Start Guide
- Initialize token module: Create
design-system/palette.ts with light/dark objects containing color roles, spacing, and typography scales.
- Choose state manager: Install
zustand and @react-native-async-storage/async-storage for lightweight setup, or @reduxjs/toolkit and redux-persist for enterprise alignment.
- Wire sync bridge: Add
ThemeSyncBridge to your root component. It listens to useColorScheme and dispatches palette resolution on mount and OS changes.
- Replace hardcoded colors: Audit components and swap direct hex values with
usePalette().colors.* or equivalent selector calls.
- Validate persistence: Toggle theme, kill the app, relaunch. Verify the preference survives and the correct palette resolves without flash.