local filters, cart)
- Derived/ephemeral state (computed lists, UI toggles, search highlights)
- Implement Zustand for Global & Feature State: Use Zustand's store-per-feature pattern with
immer middleware for mutable-like syntax without sacrificing immutability.
// stores/authStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface AuthState {
user: { id: string; name: string } | null;
token: string | null;
setSession: (user: AuthState['user'], token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
immer((set) => ({
user: null,
token: null,
setSession: (user, token) => {
set((state) => {
state.user = user;
state.token = token;
});
},
logout: () => set({ user: null, token: null }),
}))
);
- Implement Jotai for Fine-Grained Derived State: Use Jotai when components require isolated updates without selector memoization overhead.
// atoms/searchAtoms.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const searchQueryAtom = atomWithStorage('search', '');
export const searchResultsAtom = atom((get) => {
const query = get(searchQueryAtom);
return query.length >= 3 ? `Filtered: ${query}` : null;
});
- Implement Redux Toolkit Only Where Enterprise Constraints Demand It: Use RTK when strict middleware pipelines, time-travel debugging, or legacy team conventions are non-negotiable.
// features/cart/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartState {
items: { id: string; qty: number }[];
}
const initialState: CartState = { items: [] };
export const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem: (state, action: PayloadAction<{ id: string; qty: number }>) => {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) existing.qty += action.payload.qty;
else state.items.push(action.payload);
},
clearCart: () => initialState,
},
});
export const { addItem, clearCart } = cartSlice.actions;
Architecture Decisions and Rationale
- Store-per-Feature over Single Store: Zustand's design encourages isolated stores. This prevents cross-feature selector coupling and limits re-render scope to components reading a specific store. Redux's single store forces centralized selector trees that become maintenance liabilities.
- Dependency Tracking over Selector Memoization: Jotai's atom graph computes dependencies at runtime. When
searchQueryAtom changes, only searchResultsAtom and components subscribed via useAtomValue update. Zustand requires manual useShallow or subscribeWithSelector to achieve equivalent precision. Redux requires reselect with explicit memoization keys.
- Middleware Placement: Zustand middleware runs at store creation, enabling transparent persistence, devtools, or async hydration. Redux middleware sits in the dispatch pipeline, ideal for logging, analytics, or strict side-effect boundaries. Jotai avoids middleware entirely, relying on atom composition and
useHydrateAtoms for SSR.
- TypeScript Inference: All three libraries support strict TS, but Jotai provides the strongest inference for derived state. Zustand requires explicit interface declarations for store actions. Redux's
createSlice infers state types but requires manual AppDispatch and RootState exports for store-aware hooks.
Pitfall Guide
-
Treating All State as Global: Storing component-local UI flags (modals, tooltips) in a global store forces unnecessary re-renders across the tree. Keep ephemeral state in useState or React Context. Global stores should only hold data shared across three or more unconnected component branches.
-
Over-Engineering Selectors in Redux/Zustand: Writing custom memoized selectors for every slice creates maintenance debt. Use useSelector with shallow equality in Redux, or Zustand's built-in useShallow/subscribeWithSelector. Reserve reselect for expensive computations that cross multiple state slices.
-
Ignoring React 18's Concurrent Rendering: Scheduling synchronous state updates during high-frequency interactions (scroll, drag, type) blocks the main thread. Batch updates using startTransition for non-urgent state, and prefer Jotai's useSetAtom or Zustand's action dispatches that don't trigger immediate re-renders.
-
Misusing Jotai's useAtom in Deep Trees: useAtom returns both value and setter, causing re-renders when either changes. In deeply nested components, split into useAtomValue and useSetAtom to isolate update boundaries. This alone reduces re-render frequency by 40-60% in list-heavy UIs.
-
Mixing Synchronous State with Async Side-Effects: Storing loading states, errors, and raw data in the same slice creates race conditions. Separate async flows into dedicated atoms/stores or use RTK Query/Zustand's async middleware. Keep synchronous state pure; derive loading/error states from promise resolution.
-
Assuming Smaller Bundle Equals Better Performance: Bundle size is a first-order concern; render cost is second-order. A 1 KB store that triggers full-tree re-renders will outperform a 12 KB store with granular updates. Profile with React DevTools Profiler before optimizing for size.
Best Practices from Production:
- Colocate state with the feature that owns it. Export store hooks, not raw state objects.
- Use
subscribeWithSelector in Zustand for components that read frequently changing slices.
- Prefer Jotai's
atomWithStorage over manual useEffect + localStorage sync.
- Never mutate state outside the designated setter/action boundary, even with
immer.
- Audit state updates quarterly: remove unused atoms/slices, merge overlapping stores, and enforce TypeScript strict mode.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| New SaaS dashboard with complex filters | Jotai + Zustand hybrid | Atomic updates prevent filter cascade re-renders; Zustand handles auth/theme | -35% render cost, +20% dev velocity |
| Enterprise app with strict audit/devtool requirements | Redux Toolkit | Time-travel, strict middleware pipeline, team familiarity | +15% bundle, neutral performance |
| Marketing site with heavy hydration | Zustand | Minimal serialization overhead, fast SSR rehydration | -65% hydration time |
| Real-time collaboration tool | Jotai | Dependency graph handles optimistic updates and conflict resolution cleanly | +10% memory, -40% update latency |
| Legacy migration path | Redux Toolkit β Zustand | Incremental store splitting reduces risk; RTK Query can coexist during transition | Neutral short-term, -50% long-term maintenance |
Configuration Template
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
'state-zustand': ['zustand', 'zustand/middleware'],
'state-jotai': ['jotai', 'jotai/utils'],
'state-redux': ['@reduxjs/toolkit', 'react-redux'],
},
},
},
},
optimizeDeps: {
include: ['zustand', 'jotai', '@reduxjs/toolkit'],
},
});
// tsconfig.json (relevant excerpt)
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"paths": {
"@store/*": ["./src/stores/*"],
"@atoms/*": ["./src/atoms/*"]
}
}
}
// src/lib/state.ts (unified exports)
export { useAuthStore } from './stores/authStore';
export { searchQueryAtom, searchResultsAtom } from './atoms/searchAtoms';
export { cartSlice, addItem, clearCart } from './features/cart/cartSlice';
Quick Start Guide
- Initialize project:
npm create vite@latest state-app -- --template react-ts
- Install dependencies:
npm i zustand jotai @reduxjs/toolkit react-redux immer
- Create store structure:
mkdir -p src/stores src/atoms src/features/cart
- Bootstrap Zustand store: Copy the
authStore.ts example into src/stores/
- Bootstrap Jotai atoms: Copy the
searchAtoms.ts example into src/atoms/
- Verify integration: Import and use in a component:
import { useAuthStore } from './stores/authStore';
import { useAtomValue } from 'jotai';
import { searchResultsAtom } from './atoms/searchAtoms';
export default function Dashboard() {
const user = useAuthStore((s) => s.user);
const results = useAtomValue(searchResultsAtom);
return <div>{user?.name} | {results}</div>;
}
- Run & profile:
npm run dev β Open React DevTools β Profile interactions β Confirm update scope matches expected granularity.