State management is the backbone of every non-trivial React application. Choosing the wrong tool costs you bundle size, re-render budgets, and your team's sanity. This post tears apart five options — Redux Toolkit, Zustand, Hookstate (core), useContext, and useReducer — comparing DX, performance, bundle cost, and the exact scenarios each one wins.
2. Zustand
Zustand is the pragmatist's pick. ~1.1 kB, no Provider, no boilerplate, subscriptions out of the box. It occupies the sweet spot between 'just useState' and 'full Redux'.
Setup
npm install zustand
// store/useCounterStore.ts import { create } from 'zustand'; interface CounterState { count: number; increment: () => void; setBy: (n: number) => void; reset: () => void; } export const useCounterStore = create<CounterState>((set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })), setBy: (n) => set((s) => ({ count: s.count + n })), reset: () => set({ count: 0 }), })); // Component — no Provider needed const count = useCounterStore((s) => s.count); const increment = useCounterStore((s) => s.increment); // ⚠️ Anti-pattern: this re-renders on ANY store change // const { count, increment } = useCounterStore();
Middleware Stack
import { create } from 'zustand'; import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; const useStore = create<State>()( devtools( persist( immer((set) => ({ count: 0, increment: () => set((s) => { s.count++ }), // Immer mutation style })), { name: 'counter-storage' } ) ) );
When to choose Zustand
- Mid-size apps or feature stores that don't need the full Redux ceremony.
- Avoiding Provider hell — Zustand stores live outside React's tree.
- Sharing state between distant components without prop-drilling.
- Quick async actions — call fetch inside actions, call set when done.
Challenge: No built-in derived state (selectors). You write them manually or reach for zustand/middleware. Also: no opinionated async pattern — that's freedom and rope.
3. Hookstate (core)
Hookstate takes a different bet: proxy-based state that only re-renders the component that consumed the specific slice that changed. No manual selector needed — the library tracks access at the property level.
Setup
npm install @hookstate/core
// store/counterState.ts import { hookstate, useHookstate } from '@hookstate/core'; // Global state — created once, lives outside React const counterState = hookstate({ count: 0, step: 1 }); export function useCounter() { const state = useHookstate(counterState); return { count: state.count.get(), increment: () => state.count.set((c) => c + state.step.get()), setStep: (n: number) => state.step.set(n), }; } // Component — only re-renders when state.count changes // A sibling watching state.step will NOT re-render on count change function Counter() { const { count, increment } = useCounter(); return <button onClick={increment}>{count}</button>; }
Local state with Hookstate
// Replace useState for complex local objects function Form() { const form = useHookstate({ name: '', email: '', age: 0 }); return ( <input value={form.name.get()} onChange={(e) => form.name.set(e.target.value)} /> ); }
When to choose Hookstate
- High-frequency updates (live data, games, dashboards) where fine-grained re-rendering matters.
- You want MobX-like reactivity without MobX's class-based ceremony.
- Large state objects where you want automatic sub-tree subscriptions.
Challenge: Proxy-based magic can bite you in non-proxy environments (SSR, React Native). The .get() / .set() API is unfamiliar. Smaller community than Redux or Zustand.
4. useContext
Context is React's broadcast channel. Every consumer re-renders when the context value changes — the most misunderstood performance trap in React.
Pattern: Context + memoization
// ThemeContext.tsx const ThemeContext = createContext<ThemeState | null>(null); export function ThemeProvider({ children }: PropsWithChildren) { const [theme, setTheme] = useState<'light' | 'dark'>('light'); // Memoize to prevent every render creating a new object reference const value = useMemo(() => ({ theme, setTheme }), [theme]); return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; } export const useTheme = () => { const ctx = useContext(ThemeContext); if (!ctx) throw new Error('useTheme must be inside ThemeProvider'); return ctx; };
The re-render problem
If your context value is an object that changes frequently, split it into multiple contexts — one for stable values, one for volatile ones.
// ❌ Every consumer re-renders on count change const AppContext = createContext({ user, theme, count }); // ✅ Split: user/theme rarely change; count is volatile const UserContext = createContext<User>(null!); const ThemeContext = createContext<Theme>(null!); const CountContext = createContext<number>(0);
When to choose useContext
- True app-wide config: theme, locale, feature flags, auth user.
- Props that need to cross 3+ component layers (avoid prop-drilling).
- Data that changes infrequently — avoids the mass re-render footgun.
Challenge: Context is NOT a state manager — it's a dependency injector. Using it for high-frequency state is a perf anti-pattern. React 19's use(Context) helps but doesn't fix the broadcast model.
5. useReducer
useReducer is useState with a dispatch pattern. It's the built-in alternative to Redux for local or moderately complex state — same mental model, zero dependencies.
Classic counter
type Action = | { type: 'INCREMENT' } | { type: 'SET_BY'; payload: number } | { type: 'RESET' }; function reducer(state: { count: number }, action: Action) { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }; case 'SET_BY': return { count: state.count + action.payload }; case 'RESET': return { count: 0 }; default: return state; } } const [state, dispatch] = useReducer(reducer, { count: 0 }); dispatch({ type: 'SET_BY', payload: 5 });
Pair with Context for global reach
// Escape hatch: share dispatch without sharing state re-renders const DispatchContext = createContext<Dispatch<Action>>(null!); // Consumers that only dispatch never re-render on state change const dispatch = useContext(DispatchContext); dispatch({ type: 'INCREMENT' });
When to choose useReducer
- A single component or closely related component tree owns the state.
- You have 3+ state fields that update together (form, wizard, stepper).
- You want the Redux dispatch pattern without adding a dependency.
- State transitions need to be pure and easy to unit-test.
Challenge: Stays local — sharing requires Context, at which point you're assembling a DIY Redux. Once you've wired up reducer + context + dispatch across 5 files, just use Zustand.
Decision Tree: Which to Pick
Is state local to one component or subtree? └── Yes → useState / useReducer Does state update at high frequency (>10/sec)? └── Yes → Hookstate (proxy-based fine-grained updates) Do you need server-state caching (loading/error/refetch)? └── Yes → React Query + Zustand (or RTK Query if already on Redux) Large team, strict data contracts, time-travel debugging needed? └── Yes → Redux Toolkit Mid-size app, low boilerplate, no Provider? └── Yes → Zustand Infrequent global config (theme, auth, locale)? └── Yes → useContext
