DevPath · Learn to code ESPTEN

Data and global state

Global state: beyond Context

Reminder: Context

useContext solves prop drilling: instead of passing a prop through ten levels, a Provider offers a value and any descendant reads it. It's perfect for data that changes little: the theme (light/dark), the language, the authenticated user.

Where Context falls short

Context was not designed as a high-performance state manager. Its main limitation is re-renders:

When a Provider's value changes, all the components that consume that context re-render, even if they only care about one part of the value.

If you put a large object into a single context ({ user, cart, settings, notifications }) and one property changes, all the consumers re-render. Context has no selectors: you can't subscribe only to cart.total and ignore the rest. In an app with state that changes often, this translates into unnecessary renders and the hand-made solution (splitting into many contexts, memoizing) becomes cumbersome.

That's where global state libraries come in, whose great advantage is selectors: each component subscribes only to the slice of state it uses.

Zustand

Zustand is minimalist. You create a store with create passing it a function that defines state and actions:

import { create } from "zustand";

const useCounter = create((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
  reset: () => set({ count: 0 }),
}));

The store itself is a hook. You use it with a selector to read only what you need:

function Scoreboard() {
  // Re-renders ONLY if 'count' changes
  const count = useCounter((s) => s.count);
  return <p>Count: {count}</p>;
}

function Button() {
  // This one only reads the action: it doesn't re-render when 'count' changes
  const increment = useCounter((s) => s.increment);
  return <button onClick={increment}>+1</button>;
}

No Provider wrapping the app is needed, there's no boilerplate and the selector (s) => s.count avoids the re-renders Context would suffer.

Redux Toolkit

Redux is the classic of predictable global state: a single store, the state only changes by dispatching actions that pass through pure reducers. Redux Toolkit (RTK) is the official and modern way to use it, and reduces a lot of repetitive code thanks to slices:

import { configureStore, createSlice } from "@reduxjs/toolkit";

const counterSlice = createSlice({
  name: "counter",
  initialState: { count: 0 },
  reducers: {
    increment: (state) => { state.count += 1; }, // Immer: you "mutate" a draft
    reset: (state) => { state.count = 0; },
  },
});

export const { increment, reset } = counterSlice.actions;

export const store = configureStore({
  reducer: { counter: counterSlice.reducer },
});

In components you read with useSelector (with its selector, like in Zustand) and dispatch actions with useDispatch:

import { useSelector, useDispatch } from "react-redux";

function Scoreboard() {
  const count = useSelector((s) => s.counter.count);
  const dispatch = useDispatch();
  return (
    <button onClick={() => dispatch(increment())}>
      Count: {count}
    </button>
  );
}

Inside an RTK reducer it looks like you mutate the state (state.count += 1), but under the hood it uses Immer to produce a new immutable state safely.

Which one to choose?

And remember: for server data, none of these; use TanStack Query or SWR. Global state is for client state.

(Zustand and Redux are installed as dependencies; here we see them conceptually.)

Put this into practice

DevPath is a hands-on course: you read the theory here; in the app you put it into practice with exercises that really run, offline.

Start free in the app →
← Modern data fetching: TanStack Query and SWRYou Might Not Need an Effect →