DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

State Management in React: The Ultimate Guide — Context API, Redux, Zustand, Jotai & More

State Management in React: The Ultimate Guide — Context API, Redux, Zustand, Jotai & More

If you've built anything non-trivial in React, you've hit the wall. You know the one — where props are being passed through five components that don't even use them, your state logic is scattered across files, and you're questioning every architectural decision you've ever made.

State management is one of the most debated topics in the React ecosystem, and for good reason. The landscape has shifted dramatically over the past few years. Redux used to be the unquestioned default. Now? You've got Zustand, Jotai, Valtio, Signals, and a dozen other options — each with their own philosophy on how state should work.

This guide breaks down everything: the built-in tools React gives you, the major third-party libraries, when to use what, and the mistakes that will cost you performance and sanity.


Why State Management Matters

Let's start with the problem. React components are functions that take props and return UI. Simple enough — until your app grows.

The Prop Drilling Problem

          ┌──────────────┐
          │     App       │  ← user state lives here
          └──────┬───────┘
                 │ passes user as prop
          ┌──────▼───────┐
          │   Dashboard   │  ← doesn't use user, just passes it down
          └──────┬───────┘
                 │ passes user as prop
          ┌──────▼───────┐
          │   Sidebar     │  ← doesn't use user, just passes it down
          └──────┬───────┘
                 │ passes user as prop
          ┌──────▼───────┐
          │  UserAvatar   │  ← finally uses user
          └──────────────┘
Enter fullscreen mode Exit fullscreen mode

This is prop drilling. Dashboard and Sidebar don't care about user — they're just couriers. When you need to change the shape of that data or add new shared state, you're editing components that have nothing to do with the change.

Shared State Across the Tree

Some state genuinely needs to be accessed by components in completely different parts of the tree:

  • Authentication status (header, sidebar, protected routes, API calls)
  • Theme preferences (every styled component)
  • Shopping cart (product page, cart icon, checkout flow)
  • Notification system (triggered anywhere, displayed in a fixed position)

Without a state management strategy, you end up lifting state to the root component and drilling everything down. That's not maintainable.


The Five Types of State

Before picking a tool, understand what kind of state you're actually dealing with. Not all state is created equal.

Type What It Is Examples Typical Tool
Local State State owned by a single component Form input value, toggle open/closed, accordion expanded useState, useReducer
Global State State shared across many components Auth user, theme, language, feature flags Context, Redux, Zustand
Server State Data from your backend/API User profile from DB, product list, search results TanStack Query, RTK Query, SWR
URL State State encoded in the URL Search filters, pagination, selected tab useSearchParams, Next.js router
Form State Complex form data with validation Multi-step form, dynamic fields, validation errors React Hook Form, Formik

One of the most common mistakes is treating server state like global state. If data comes from an API, you almost certainly want a dedicated server-state library (TanStack Query, SWR) rather than stuffing API responses into Redux or Zustand. These libraries handle caching, refetching, deduplication, and background updates — things you'd have to build yourself otherwise.


Built-in React State Tools

React ships with three primitives for state management. For many apps, they're all you need.

useState — The Workhorse

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useState is perfect for local, component-scoped state. Don't overthink it — if a piece of state is only used in one component (or passed one level down), useState is the answer.

Pro tip: Always use the functional updater (setCount(c => c + 1)) when the new state depends on the previous state. This avoids stale closure bugs, especially in event handlers and effects.

useReducer — When State Logic Gets Complex

When state transitions become complex or interrelated, useReducer brings structure:

import { useReducer } from 'react';

const initialState = {
  items: [],
  loading: false,
  error: null,
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
      };
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
      };
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, items: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const addItem = (item) => dispatch({ type: 'ADD_ITEM', payload: item });

  return (
    <div>
      {state.loading && <p>Loading...</p>}
      {state.error && <p>Error: {state.error}</p>}
      {state.items.map(item => (
        <CartItem key={item.id} item={item} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use useReducer when:

  • State has multiple sub-values
  • Next state depends on previous state in complex ways
  • You want to keep state transitions testable and predictable

useContext — Sharing State Without Props

Context lets you broadcast state to any descendant component without explicit prop passing.

import { createContext, useContext, useState } from 'react';

// 1. Create context
const ThemeContext = createContext(null);

// 2. Create provider
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('dark');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// 3. Custom hook for consuming
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

// 4. Use anywhere in the tree
function Header() {
  const { theme, setTheme } = useTheme();
  return (
    <header className={theme}>
      <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>
        Toggle Theme
      </button>
    </header>
  );
}
Enter fullscreen mode Exit fullscreen mode

Context API Deep Dive — When It's Enough (And When It's Not)

Context is powerful, but it has a critical performance characteristic that trips people up.

The Re-rendering Problem

When a Context value changes, every component that consumes that context re-renders — even if it only uses a portion of the value that didn't change.

// This is a performance trap
const AppContext = createContext(null);

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('dark');
  const [notifications, setNotifications] = useState([]);

  // Every consumer re-renders when ANY of these change
  return (
    <AppContext.Provider value={{
      user, setUser,
      theme, setTheme,
      notifications, setNotifications,
    }}>
      {children}
    </AppContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

When notifications updates, the ThemeToggle component (which only reads theme) re-renders unnecessarily. In a large app, this cascading re-render can be a real performance issue.

Mitigations

1. Split into separate contexts:

// Better: separate concerns
<UserProvider>
  <ThemeProvider>
    <NotificationProvider>
      {children}
    </NotificationProvider>
  </ThemeProvider>
</UserProvider>
Enter fullscreen mode Exit fullscreen mode

2. Memoize the context value:

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('dark');

  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Separate state and dispatch contexts:

const ThemeStateContext = createContext(null);
const ThemeDispatchContext = createContext(null);

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('dark');

  return (
    <ThemeStateContext.Provider value={theme}>
      <ThemeDispatchContext.Provider value={setTheme}>
        {children}
      </ThemeDispatchContext.Provider>
    </ThemeStateContext.Provider>
  );
}

// Components that only dispatch don't re-render when state changes
function ThemeToggle() {
  const setTheme = useContext(ThemeDispatchContext); // stable reference
  return <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>Toggle</button>;
}
Enter fullscreen mode Exit fullscreen mode

When Context Is Enough

Context works great for:

  • Values that change infrequently (theme, locale, auth status)
  • Small apps with limited shared state
  • Dependency injection (passing services/configs down the tree)

Context is probably not enough when:

  • You have high-frequency updates (e.g., a real-time dashboard)
  • Many consumers only need slices of state
  • You need middleware, devtools, or time-travel debugging
  • State logic is complex enough to benefit from structured patterns

Redux Toolkit — Modern Redux

If you tried Redux in 2018 and hated the boilerplate, give Redux Toolkit (RTK) a look. It's a completely different experience.

createSlice — State + Reducers in One Place

// store/features/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [],
    filter: 'all',
  },
  reducers: {
    addTodo: (state, action) => {
      // RTK uses Immer under the hood — "mutating" is fine here
      state.items.push({
        id: crypto.randomUUID(),
        text: action.payload,
        completed: false,
      });
    },
    toggleTodo: (state, action) => {
      const todo = state.items.find(t => t.id === action.payload);
      if (todo) todo.completed = !todo.completed;
    },
    removeTodo: (state, action) => {
      state.items = state.items.filter(t => t.id !== action.payload);
    },
    setFilter: (state, action) => {
      state.filter = action.payload;
    },
  },
});

export const { addTodo, toggleTodo, removeTodo, setFilter } = todosSlice.actions;
export default todosSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

configureStore — One-Line Store Setup

// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './features/todosSlice';
import authReducer from './features/authSlice';

export const store = configureStore({
  reducer: {
    todos: todosReducer,
    auth: authReducer,
  },
  // DevTools, thunk middleware, and serialization checks included by default
});
Enter fullscreen mode Exit fullscreen mode

Using in Components

import { useSelector, useDispatch } from 'react-redux';
import { addTodo, toggleTodo, setFilter } from '../store/features/todosSlice';

function TodoList() {
  const { items, filter } = useSelector(state => state.todos);
  const dispatch = useDispatch();

  const filteredItems = items.filter(todo => {
    if (filter === 'completed') return todo.completed;
    if (filter === 'active') return !todo.completed;
    return true;
  });

  return (
    <div>
      <input
        onKeyDown={(e) => {
          if (e.key === 'Enter' && e.target.value) {
            dispatch(addTodo(e.target.value));
            e.target.value = '';
          }
        }}
        placeholder="Add a todo..."
      />
      {filteredItems.map(todo => (
        <div key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
          {todo.completed ? '' : ''} {todo.text}
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

RTK Query — Built-in Data Fetching

RTK Query is Redux Toolkit's answer to TanStack Query. It auto-generates hooks for API calls:

// store/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    getPost: builder.query({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    addPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      invalidatesTags: ['Post'], // auto-refetch posts list
    }),
  }),
});

export const { useGetPostsQuery, useGetPostQuery, useAddPostMutation } = apiSlice;
Enter fullscreen mode Exit fullscreen mode
function PostsList() {
  const { data: posts, isLoading, error } = useGetPostsQuery();
  const [addPost] = useAddPostMutation();

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return (
    <div>
      <button onClick={() => addPost({ title: 'New Post', body: '...' })}>
        Add Post
      </button>
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When to Use Redux Toolkit

Redux shines when you have:

  • Complex state logic with many interacting slices
  • A large team that benefits from strict patterns and conventions
  • Need for powerful devtools (time-travel debugging, action logging)
  • Existing Redux codebase to migrate incrementally
  • Server state tightly coupled with client state (RTK Query)

Zustand — The New Favorite

Zustand (German for "state") has exploded in popularity, and for good reason. It's tiny (~1KB), has zero boilerplate compared to Redux, works outside React components, and just... makes sense.

Basic Store

// store/useCounterStore.js
import { create } from 'zustand';

const useCounterStore = create((set, get) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  // Access current state with get()
  doubleCount: () => get().count * 2,
}));

export default useCounterStore;
Enter fullscreen mode Exit fullscreen mode
function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's it. No Provider wrapping your app. No boilerplate. No ceremony.

Real-World Example: Auth Store

import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';

const useAuthStore = create(
  devtools(
    persist(
      (set, get) => ({
        user: null,
        token: null,
        isAuthenticated: false,

        login: async (email, password) => {
          try {
            const res = await fetch('/api/auth/login', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ email, password }),
            });
            const data = await res.json();

            if (!res.ok) throw new Error(data.message);

            set({
              user: data.user,
              token: data.token,
              isAuthenticated: true,
            });

            return { success: true };
          } catch (error) {
            return { success: false, error: error.message };
          }
        },

        logout: () => {
          set({ user: null, token: null, isAuthenticated: false });
        },

        updateProfile: (updates) => {
          set((state) => ({
            user: state.user ? { ...state.user, ...updates } : null,
          }));
        },
      }),
      {
        name: 'auth-storage', // localStorage key
        partialize: (state) => ({
          token: state.token,
          user: state.user,
        }), // only persist these fields
      }
    ),
    { name: 'AuthStore' } // devtools label
  )
);
Enter fullscreen mode Exit fullscreen mode

Slices Pattern — Scaling Zustand

For larger apps, you can compose stores using the slices pattern:

// store/slices/userSlice.js
export const createUserSlice = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
  clearUser: () => set({ user: null }),
});

// store/slices/cartSlice.js
export const createCartSlice = (set, get) => ({
  items: [],
  addItem: (item) => set((state) => ({
    items: [...state.items, item],
  })),
  total: () => get().items.reduce((sum, item) => sum + item.price, 0),
});

// store/index.js
import { create } from 'zustand';
import { createUserSlice } from './slices/userSlice';
import { createCartSlice } from './slices/cartSlice';

const useBoundStore = create((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));
Enter fullscreen mode Exit fullscreen mode

Why Zustand Is Winning

  • No Provider needed — works outside the React tree
  • Selector-based re-rendering — only re-renders when selected state changes
  • Tiny bundle — ~1KB gzipped
  • Middleware — persist, devtools, immer, subscribeWithSelector
  • Works outside React — call useStore.getState() in utility functions
  • TypeScript-first — great inference without extra types
  • SSR-friendly — works well with Next.js

Jotai — Atomic State Management

Jotai takes a fundamentally different approach. Instead of a single store, state is built from the bottom up using atoms — independent, composable units of state.

Basic Atoms

import { atom, useAtom } from 'jotai';

// Primitive atoms
const countAtom = atom(0);
const nameAtom = atom('');

// Derived atom (read-only, computed from other atoms)
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// Writable derived atom
const uppercaseNameAtom = atom(
  (get) => get(nameAtom).toUpperCase(),           // getter
  (get, set, newName) => set(nameAtom, newName),   // setter
);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubleCount] = useAtom(doubleCountAtom);

  return (
    <div>
      <p>Count: {count} (Double: {doubleCount})</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Async Atoms

const userIdAtom = atom(1);

const userAtom = atom(async (get) => {
  const id = get(userIdAtom);
  const res = await fetch(`/api/users/${id}`);
  return res.json();
});

function UserProfile() {
  const [user] = useAtom(userAtom); // Suspense-compatible

  return <div>{user.name}</div>;
}

// Wrap in Suspense
<Suspense fallback={<Spinner />}>
  <UserProfile />
</Suspense>
Enter fullscreen mode Exit fullscreen mode

Atom Families — Dynamic Atoms

import { atomFamily } from 'jotai/utils';

const todoAtomFamily = atomFamily((id) =>
  atom({
    id,
    text: '',
    completed: false,
  })
);

// Each todo gets its own atom — granular re-renders
function TodoItem({ id }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id));

  return (
    <div>
      <input
        value={todo.text}
        onChange={(e) => setTodo({ ...todo, text: e.target.value })}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When Atoms Shine

Jotai is excellent when:

  • You have many independent pieces of state that compose together
  • You need granular re-rendering (only the atom consumer re-renders)
  • You like building state bottom-up rather than top-down
  • You want Suspense integration for async data
  • Your state graph has complex dependencies between values

The Other Contenders

Recoil

Built by Facebook, Recoil pioneered the atomic state model that Jotai refined. It introduced atoms and selectors, and integrates well with React's concurrent features.

However, as of 2026, Recoil's maintenance has slowed significantly. The package hasn't seen a stable 1.0 release, and the community has largely migrated to Jotai for the atomic model. If you're starting a new project, Jotai is the safer bet. If you have an existing Recoil codebase, there's no rush to migrate — it still works — but don't expect much new development.

Valtio — Proxy-Based State

Valtio uses JavaScript Proxies to make state management feel like plain mutation:

import { proxy, useSnapshot } from 'valtio';

const state = proxy({
  count: 0,
  todos: [],
});

// Mutations just work — no set(), no dispatch, no action creators
function increment() {
  state.count++;
}

function addTodo(text) {
  state.todos.push({ id: Date.now(), text, completed: false });
}

// In components, useSnapshot tracks which properties you read
function Counter() {
  const snap = useSnapshot(state);
  // Only re-renders when count changes, not when todos change
  return <p>{snap.count}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Valtio is great if you prefer a mutable programming style and want automatic render optimization. It's from the same author as Zustand (Daishi Kato), so the quality is solid.

MobX — Observable-Based

MobX has been around since 2015 and uses observables and decorators for automatic dependency tracking:

import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

class TimerStore {
  seconds = 0;

  constructor() {
    makeAutoObservable(this);
  }

  increment() {
    this.seconds++;
  }

  get formatted() {
    return `${Math.floor(this.seconds / 60)}:${(this.seconds % 60)
      .toString()
      .padStart(2, '0')}`;
  }
}

const timerStore = new TimerStore();

const TimerView = observer(({ store }) => (
  <div>
    <p>{store.formatted}</p>
    <button onClick={() => store.increment()}>+1s</button>
  </div>
));
Enter fullscreen mode Exit fullscreen mode

MobX is powerful and has a loyal following, especially teams coming from object-oriented backgrounds. Its auto-tracking is genuinely magical — it knows exactly which observables a component reads and only re-renders when those change. The downside is the implicit nature of reactivity can make debugging harder, and the observer HOC wrapping is an extra step.

Signals — The Trend to Watch

Signals are a reactivity primitive gaining traction across the frontend ecosystem. Preact Signals, SolidJS, Angular's signals, and the TC39 Signals proposal are all converging on this pattern.

// Using @preact/signals-react (experimental React integration)
import { signal, computed } from '@preact/signals-react';

const count = signal(0);
const doubled = computed(() => count.value * 2);

function Counter() {
  return (
    <div>
      <p>{count} (doubled: {doubled})</p>
      <button onClick={() => count.value++}>+1</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Signals bypass React's reconciliation — they update the DOM directly when a signal value changes, without triggering a component re-render. This is fundamentally different from how React works, and the integration story with React is still evolving. Keep an eye on this space, but for production React apps today, the established libraries are safer choices.


Head-to-Head Comparison

Feature Context API Redux Toolkit Zustand Jotai Valtio MobX
Bundle Size 0 (built-in) ~11KB ~1KB ~2KB ~3KB ~16KB
Boilerplate Low Medium Very Low Very Low Very Low Low
Learning Curve Low Medium-High Low Low-Medium Low Medium
DevTools React DevTools Redux DevTools (excellent) Redux DevTools (via middleware) Jotai DevTools Valtio DevTools MobX DevTools
TypeScript Good Excellent Excellent Excellent Good Good
Re-render Optimization Poor (all consumers) Good (selectors) Excellent (selectors) Excellent (atomic) Excellent (proxy tracking) Excellent (auto-tracking)
Middleware None Extensive persist, devtools, immer Extensive utils Limited Reactions, interceptors
SSR Support Native Good Good Good Good Requires setup
Works Outside React No Yes (store.getState) Yes (getState) Limited Yes (direct mutation) Yes (autorun)
Async Support Manual Thunks, RTK Query Manual / middleware Built-in (Suspense) Manual Flows
Provider Required Yes Yes No Yes (optional) No No (but context recommended)
Community / Ecosystem React core Massive Growing fast Growing Moderate Large, stable

State Management Decision Flowchart

                        ┌─────────────────────────┐
                        │  Is it server/API data?  │
                        └────────────┬────────────┘
                                     │
                        ┌────── YES ─┤── NO ──────┐
                        ▼                          ▼
              ┌─────────────────┐      ┌─────────────────────┐
              │ Use TanStack    │      │ Is it local to one   │
              │ Query or SWR    │      │ component?            │
              └─────────────────┘      └──────────┬──────────┘
                                                  │
                                     ┌──── YES ───┤─── NO ────┐
                                     ▼                         ▼
                           ┌──────────────┐     ┌───────────────────────┐
                           │ useState or  │     │ Is it URL state?       │
                           │ useReducer   │     │ (filters, pagination)  │
                           └──────────────┘     └───────────┬───────────┘
                                                            │
                                               ┌──── YES ──┤── NO ──┐
                                               ▼                     ▼
                                    ┌────────────────┐   ┌─────────────────────┐
                                    │ useSearchParams │   │ How complex is the  │
                                    │ or router state │   │ shared state?       │
                                    └────────────────┘   └──────────┬──────────┘
                                                                    │
                                                    ┌─── SIMPLE ───┤── COMPLEX ──┐
                                                    ▼                             ▼
                                          ┌──────────────────┐       ┌──────────────────┐
                                          │ Context API or   │       │ Team preference:  │
                                          │ Zustand          │       │                   │
                                          └──────────────────┘       │ - Structured?     │
                                                                     │   → Redux Toolkit │
                                                                     │ - Minimal?        │
                                                                     │   → Zustand       │
                                                                     │ - Atomic?         │
                                                                     │   → Jotai         │
                                                                     │ - Mutable style?  │
                                                                     │   → Valtio / MobX │
                                                                     └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

1. Putting Everything in Global State

Not every piece of state needs to be global. A modal's open/closed state? useState. A form's input values? React Hook Form or local state. If only one component (or its direct children) cares about a value, keep it local.

2. Using Global State for Server Data

// Don't do this
const useStore = create((set) => ({
  users: [],
  fetchUsers: async () => {
    const res = await fetch('/api/users');
    const data = await res.json();
    set({ users: data });
  },
}));
Enter fullscreen mode Exit fullscreen mode

This looks clean, but you're missing: caching, background refetching, deduplication, pagination, optimistic updates, error retry, and stale-while-revalidate. Use TanStack Query or SWR instead.

3. Not Using Selectors (Re-rendering the World)

// Bad: re-renders on ANY store change
const store = useStore();

// Good: only re-renders when count changes
const count = useStore((state) => state.count);
Enter fullscreen mode Exit fullscreen mode

This applies to Redux (useSelector), Zustand, and any selector-based library.

4. Massive Monolithic Stores

Don't put your entire app state in a single store object. Split by domain:

store/
  useAuthStore.js
  useCartStore.js
  useUIStore.js
  usePreferencesStore.js
Enter fullscreen mode Exit fullscreen mode

5. Ignoring React Server Components

If you're using Next.js App Router or any RSC framework, remember that server components can't use hooks or state. State management only applies to client components. Fetch data in server components and pass it down — you might need less client-side state than you think.


Best Practices

  1. Start simple. Don't add a state library on day one. Use useState and useReducer. Add a library when you feel actual pain, not anticipated pain.

  2. Classify your state. Before choosing a tool, identify whether it's local, global, server, URL, or form state. Each has a best-fit solution.

  3. Use server state libraries for server data. TanStack Query or SWR for REST, Apollo Client or urql for GraphQL. These aren't optional nice-to-haves — they handle a massive amount of complexity you'd otherwise build yourself.

  4. Colocate state. Keep state as close to where it's used as possible. Lift it up only when necessary.

  5. Use selectors everywhere. Whether it's Redux's useSelector, Zustand's selector argument, or Jotai's atoms — always select the minimum state a component needs.

  6. Persist thoughtfully. Libraries like Zustand's persist middleware make it easy to save state to localStorage. But be careful what you persist — stale auth tokens, outdated cache data, or large datasets can cause bugs.

  7. Type your state. TypeScript catches a huge class of state management bugs at compile time. All modern libraries have excellent TypeScript support — use it.

  8. Keep actions with state. Define your update logic (actions/mutations) next to the state it modifies, not scattered across components.


The Bottom Line

The React state management ecosystem in 2026 is mature and diverse. There's no single "right" answer — but there are clear guidelines:

  • Solo dev, small-medium app? Zustand. It does everything you need with zero friction.
  • Large team, complex domain? Redux Toolkit. The structure and tooling pay off at scale.
  • Granular, composable state? Jotai. Atoms are a beautiful mental model.
  • Prefer mutable style? Valtio. Proxy magic with automatic optimization.
  • Just need to share a few values? Context API. Don't overcomplicate it.

And always — always — use a dedicated server-state library for API data. That single decision eliminates more state management complexity than any library choice.


If you found this guide useful, let's connect! I write about frontend architecture, React patterns, and developer tooling.

Connect with me on LinkedIn

Top comments (0)