s a layered approach. The solution involves categorizing state by origin and frequency, then applying the appropriate mechanism.
1. Separate Server State from Client State
Server state should never reside in Redux or Zustand. Use a data-fetching library to manage caching and synchronization.
// server-state.ts
import { useQuery, useMutation } from '@tanstack/react-query';
interface User {
id: string;
name: string;
role: 'admin' | 'user';
}
export const useUserProfile = (userId: string) => {
return useQuery<User, Error>({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
export const useUpdateUserRole = () => {
return useMutation({
mutationFn: async ({ userId, role }: { userId: string; role: string }) => {
await fetch(`/api/users/${userId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
});
},
// Invalidate cache to trigger refetch
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['user', variables.userId] });
},
});
};
2. Implement Context for Low-Frequency, Tree-Wide Values
Use Context for values that change rarely and are consumed across disparate parts of the tree. Examples include theme settings, locale, or feature flags.
// theme-context.tsx
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react';
type ThemeMode = 'system' | 'light' | 'dark';
interface ThemeContextValue {
mode: ThemeMode;
toggleMode: () => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mode, setMode] = useState<ThemeMode>('system');
const toggleMode = useCallback(() => {
setMode((prev) => (prev === 'light' ? 'dark' : 'light'));
}, []);
// Memoize value to prevent unnecessary re-renders when mode hasn't changed
const value = useMemo(() => ({ mode, toggleMode }), [mode, toggleMode]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within a ThemeProvider');
return context;
};
3. Use Zustand for Client State with Fine-Grained Updates
Zustand provides a store with selector-based subscriptions without the boilerplate of Redux. It is ideal for client state that updates frequently or requires complex logic.
// workspace-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface WorkspaceState {
activeTabId: string | null;
tabs: Array<{ id: string; title: string }>;
setActiveTab: (id: string) => void;
addTab: (title: string) => void;
closeTab: (id: string) => void;
}
export const useWorkspaceStore = create<WorkspaceState>()(
persist(
(set, get) => ({
activeTabId: null,
tabs: [],
setActiveTab: (id) => set({ activeTabId: id }),
addTab: (title) => {
const newTab = { id: crypto.randomUUID(), title };
set((state) => ({
tabs: [...state.tabs, newTab],
activeTabId: newTab.id,
}));
},
closeTab: (id) => {
const state = get();
const remainingTabs = state.tabs.filter((t) => t.id !== id);
const nextActive =
state.activeTabId === id
? remainingTabs[remainingTabs.length - 1]?.id ?? null
: state.activeTabId;
set({ tabs: remainingTabs, activeTabId: nextActive });
},
}),
{ name: 'workspace-storage' }
)
);
// Component usage with selector
export const TabCount = () => {
// Only re-renders when tab count changes
const count = useWorkspaceStore((state) => state.tabs.length);
return <span>Tabs: {count}</span>;
};
4. Deploy Redux Toolkit for Complex Async and Team Scale
Redux is warranted when applications require strict structure, audit trails, or complex middleware chains. RTK simplifies setup while maintaining predictability.
// dashboard-slice.ts
import { createSlice, createAsyncThunk, configureStore } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
interface MetricData {
value: number;
timestamp: number;
}
interface DashboardState {
metrics: MetricData[];
status: 'idle' | 'loading' | 'failed';
}
const initialState: DashboardState = {
metrics: [],
status: 'idle',
};
export const fetchMetrics = createAsyncThunk(
'dashboard/fetchMetrics',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/metrics');
if (!response.ok) throw new Error('Network error');
return response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const dashboardSlice = createSlice({
name: 'dashboard',
initialState,
reducers: {
clearMetrics: (state) => {
state.metrics = [];
state.status = 'idle';
},
},
extraReducers: (builder) => {
builder
.addCase(fetchMetrics.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchMetrics.fulfilled, (state, action: PayloadAction<MetricData[]>) => {
state.status = 'idle';
state.metrics = action.payload;
})
.addCase(fetchMetrics.rejected, (state) => {
state.status = 'failed';
});
},
});
export const { clearMetrics } = dashboardSlice.actions;
export const dashboardReducer = dashboardSlice.reducer;
export const store = configureStore({
reducer: {
dashboard: dashboardReducer,
},
});
Pitfall Guide
-
The Context Cascade
- Explanation: Using Context for state that updates frequently, such as form inputs or scroll position. This causes all consumers to re-render on every change.
- Fix: Use local
useState for form inputs. For global high-frequency state, use Zustand or Redux with selectors.
-
The Server State Mirage
- Explanation: Storing API responses in Redux or Zustand. This requires manual caching, deduplication, and revalidation logic.
- Fix: Use TanStack Query or SWR for server state. Keep client state libraries for UI-only data.
-
Zustand Sprawl
- Explanation: As teams grow, Zustand's lack of enforced structure can lead to inconsistent patterns, making the codebase hard to maintain.
- Fix: Establish strict conventions for store organization. If complexity exceeds a threshold, migrate to Redux for its structural guarantees.
-
Redux Boilerplate Anxiety
- Explanation: Avoiding Redux due to legacy boilerplate concerns. Modern RTK significantly reduces setup complexity.
- Fix: Evaluate RTK based on current capabilities. Use
createSlice and createAsyncThunk for concise logic.
-
Selector Staleness
- Explanation: Creating inline objects or functions in selectors, causing unnecessary re-renders due to reference inequality.
- Fix: Return primitives from selectors or use
useMemo/useCallback to stabilize references.
-
Context Splitting Neglect
- Explanation: Placing multiple unrelated values in a single Context provider.
- Fix: Split Contexts by domain. Separate
ThemeContext from UserContext to isolate update propagation.
-
Ignoring Bundle Impact
- Explanation: Adding state libraries without considering bundle size constraints.
- Fix: Audit dependencies. Zustand is ~1.2kb; Redux is ~10.5kb. Choose based on performance budgets.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Marketing Site | Context + useState | Zero overhead, simple structure | Low |
| SaaS Dashboard | Zustand + TanStack Query | Fast development, good performance | Medium |
| Enterprise ERP | Redux RTK + RTK Query | Audit trails, structure, team scale | High |
| Real-time App | Zustand + WebSockets | Low latency, fine-grained updates | Medium |
| Legacy Migration | Redux RTK | Predictable migration path, tooling | High |
Configuration Template
A robust Zustand store template with persistence and TypeScript support.
// store-template.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface AppState {
isLoading: boolean;
error: string | null;
setLoadState: (loading: boolean) => void;
setError: (error: string | null) => void;
reset: () => void;
}
export const useAppStore = create<AppState>()(
persist(
immer((set) => ({
isLoading: false,
error: null,
setLoadState: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
reset: () => set({ isLoading: false, error: null }),
})),
{
name: 'app-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ error: state.error }), // Persist only error
}
)
);
Quick Start Guide
- Install Dependencies:
npm install zustand @tanstack/react-query
- Setup Query Client: Initialize
QueryClient and wrap app with QueryClientProvider.
- Create Stores: Define Zustand stores for client state using selectors.
- Migrate Server State: Replace API calls in components with
useQuery hooks.
- Verify Performance: Profile re-renders and ensure updates are isolated.