Hindutva Digital
Breaking
Technology

React State Management in 2025: Redux, Zustand, Hookstate, useContext & useReducer

M
Mahesh Rathod18 May 2026
00
React State Management in 2025: Redux, Zustand, Hookstate, useContext & useReducer

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
Tags#AI#Artificial Intelligence#Machine Learning#Software#Technology Update

More in this category