Skip to main content

Zustand Concepts

In the previous lesson we saw why sharing state with props or with Context becomes uncomfortable. Zustand (German for "state") is a global state library for React that solves that problem with a very short idea: a store is a function that returns a hook.

If you already know Redux Toolkit (we studied it in Computación en Internet 3), you will be surprised by how little there is to learn: there is no dispatch, no action objects, no reducers and no Provider.


1. What Is Zustand​

Zustand is a small library with no dependencies and no strong opinions about how to organize your code. Its mental model fits in four sentences:

  1. You create a store with the create function.
  2. The store keeps state and actions in the same object.
  3. Actions change the state by calling set.
  4. Components read with the hook that create returns, indicating with a selector which data they need.
Course version

We use Zustand 5 with React 19 and Next.js 16. If you find old tutorials (version 3 or 4), most of the code still works; the difference that affects you most is explained in Zustand Capabilities.


2. Anatomy of a Store​

This is the notifications store we will use throughout the week. Read it completely once; then we take it apart piece by piece.

src/features/notifications/store/notifications.store.ts
import { create } from 'zustand';

export type NotificationItem = {
id: number;
title: string;
read: boolean;
};

type NotificationsState = {
// State
notifications: NotificationItem[];
// Actions
addNotification: (title: string) => void;
markAllRead: () => void;
};

export const useNotificationsStore = create<NotificationsState>()((set) => ({
notifications: [],

addNotification: (title) =>
set((state) => ({
notifications: [
{ id: Date.now(), title, read: false },
...state.notifications,
],
})),

markAllRead: () =>
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, read: true })),
})),
}));

create​

create receives a function that describes the store and returns a hook (by convention it is named useSomethingStore). That hook is the only thing components import.

The two pairs of parentheses

With TypeScript we write create<State>()(...): first we indicate the type and then we pass the function. This "curried" form is required when we use middleware (we will see it in lesson 4), so we always use it to avoid changing it later.

State and Actions Together​

Unlike Redux, where the state lives in a slice and actions are dispatched from somewhere else, in Zustand the state and the functions that modify it are properties of the same object. An action is simply a function.

set: Changing the State​

set is the only way to modify the state. It has two forms:

Two forms of set
// 1. With an object: when the new value does not depend on the previous one
set({ notifications: [] });

// 2. With a function: when it depends on the current state (the most common)
set((state) => ({ notifications: [...state.notifications, newItem] }));

Two rules worth memorizing:

  • set merges shallowly (shallow merge): you only need to return the properties that change. The rest are kept. You do not need to write ...state.
  • State is immutable: never do state.notifications.push(...). Return a new array ([...], map, filter). Later we will see immer, which lets you write "mutating" code without breaking the rule.

get: Reading the State Inside an Action​

The second parameter of the function passed to create is get, which returns the current state. It is useful when an action needs to read something before deciding:

Using get inside an action
export const useAuthStore = create<AuthState>()((set, get) => ({
token: null,
isAuthenticated: () => get().token !== null,
}));

Selector: Which Part of the Store I Need​

A selector is a function that receives the whole state and returns only what the component needs:

Reading with selectors
const notifications = useNotificationsStore((s) => s.notifications);
const addNotification = useNotificationsStore((s) => s.addNotification);

The component subscribes only to that value. If another part of the store changes, it does not render again. That is why selectors are not optional in practice.

Avoid calling the hook without a selector

const store = useNotificationsStore() works, but it subscribes the component to everything in the store: it renders on any change, and we lose the main advantage over Context.


3. The Data Flow​

The path of a change in Zustand is shorter than in Redux. Follow step by step what happens when the user emits a notification:

Flow of a state change in Zustand

Summary of the path:

  1. The component calls the action directly (addNotification("Hello")). There is no dispatch or action object.
  2. The action calls set with the change.
  3. The store replaces its state and notifies the subscribers.
  4. Each selector compares its previous result with the new one (using Object.is).
  5. Only the components whose selector returned a different value render again.
Why NotificationCreator does not re-render

NotificationCreator only selects the addNotification action. Store functions are created only once, so their reference never changes and that component does not find out that the list grew. It is one of the reasons why separating "reading data" from "calling actions" with different selectors is cheap and keeps performance clean.


4. Zustand Outside React​

The hook returned by create also brings three functions that work without components. This is useful for the HTTP client, a utility or a test:

getState, setState and subscribe
// Read the current state (without subscribing)
const { notifications } = useNotificationsStore.getState();

// Modify it from any file
useNotificationsStore.getState().addNotification('Saved');
useNotificationsStore.setState({ notifications: [] });

// Listen to changes (returns the function to cancel the subscription)
const unsubscribe = useNotificationsStore.subscribe((state) => {
console.log('There are now', state.notifications.length);
});

A classic example: the week 12 apiFetch can read the token with useAuthStore.getState().token without being a component or a hook.


5. Comparison: Context, Zustand and Redux Toolkit​

No tool is the best in every scenario. This is the same comparison we saw in Redux, now with Zustand in the center:

State management comparison: React Context vs Zustand vs Redux ToolkitState management comparison: React Context vs Zustand vs Redux Toolkit
CriterionReact ContextZustandRedux Toolkit
Pieces you learncreateContext, Provider, useContextcreate, set, selectorconfigureStore, slice, reducer, action, dispatch, Provider, selector
BoilerplateMediumMinimalModerate
Provider requiredYesNo (in the simple version)Yes
Re-rendersAll consumersBy selectorBy selector
Data flowFreeFree (you decide the structure)Strict and unidirectional
Async effectsYou solve itNormal async actionsThunks / createAsyncThunk
Debugging toolsReact DevToolsdevtools middleware (Redux DevTools)Native Redux DevTools
Convention imposed on the teamNoneNoneHigh
When it fitsAlmost static data (theme, language)Small and medium apps, UI and session stateLarge teams, complex domains, traceability

Same Change, Two Tools​

Adding a notification in Redux Toolkit and in Zustand:

Redux Toolkit: several pieces
// slice
reducers: { add: (state, action: PayloadAction<string>) => { state.items.unshift(...) } }
// component
const dispatch = useAppDispatch();
dispatch(add('Hello'));
const items = useAppSelector((s) => s.notifications.items);
Zustand: the store and done
// store
addNotification: (title) => set((s) => ({ notifications: [...] }))
// component
const addNotification = useNotificationsStore((s) => s.addNotification);
addNotification('Hello');
const notifications = useNotificationsStore((s) => s.notifications);
Freedom versus convention

That Zustand does not impose structure is its advantage and its risk. In a large team, without agreements you end up with twenty different ways of writing a store. That is why this week we also set where stores live and how they are named (next lesson).


Self-Assessment Quiz​

Cargando cuestionario...