So far we kept state with useState, useReducer and context. For many apps that is enough. But as an app grows, you may want one central store that any component can read and change, with good tools. In this lesson we look at the two most popular choices: Redux Toolkit and Zustand.
Do you need a state library?
Most beginners reach for a library too early. First, ask what kind of state you have:
- Local UI state (a toggle, a text box): use
useState. Keep it local. - Server data (lists from an API): use a data library like TanStack Query (next lesson). This is the biggest source of state in many apps, and a state library is *not* the best tool for it.
- Shared client state (the cart, the signed-in user, filters used on many pages): this is where a store helps.
A library may be worth adding when many components far apart change the same data, when the update rules are complex, or when you want good debugging tools. If a few contexts do the job, keep them.
Redux in one picture
Redux keeps all shared state in one store. Components do not change it directly. They dispatch actions, and reducers (pure functions) make the new state, the same idea you learned in the useReducer lesson. Redux Toolkit (RTK) is the modern, official way to write Redux. It removes most of the old boilerplate.
npm install @reduxjs/toolkit react-redux
A cart with Redux Toolkit
Step 1: make a slice, which holds the state, the reducers and the actions of one feature together:
// cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
added(state, action) {
const found = state.items.find((i) => i.id === action.payload.id);
if (found) {
found.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removed(state, action) {
state.items = state.items.filter((i) => i.id !== action.payload);
},
cleared(state) {
state.items = [];
},
},
});
export const { added, removed, cleared } = cartSlice.actions;
export default cartSlice.reducer;
Look: inside the reducers it seems we change the state with push and += 1. That is allowed here, because RTK uses a library called Immer behind the scenes. Immer lets you write simple “changes”, and it turns them into safe, new copies for you. This works only inside createSlice reducers.
Step 2: make the store and give it to the app:
// store.js
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: { cart: cartReducer },
});
// main.jsx
import { Provider } from 'react-redux';
import { store } from './store';
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
);
Step 3: read and change the store in components, with useSelector and useDispatch:
import { useSelector, useDispatch } from 'react-redux';
import { added, removed } from './cartSlice';
function CartCount() {
const count = useSelector((state) =>
state.cart.items.reduce((sum, i) => sum + i.quantity, 0)
);
return <span>Cart ({count})</span>;
}
function ProductCard({ product }) {
const dispatch = useDispatch();
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => dispatch(added(product))}>Add to cart</button>
</div>
);
}
useSelector(fn)picks the part of the state you need. The component redraws only when that picked value changes.useDispatch()gives thedispatchfunction.added(product)makes the action{ type: 'cart/added', payload: product }.- Select the smallest piece you need, not the whole state, so fewer components redraw.
Redux also has excellent developer tools (the Redux DevTools browser extension), where you can see every action, and go back in time. For big teams and complex apps this is very valuable.
The same cart with Zustand
Zustand is a very small library with almost no setup. There is no provider and no actions to write. You make a store as a hook:
npm install zustand
// useCartStore.js
import { create } from 'zustand';
export const useCartStore = create((set) => ({
items: [],
add: (product) =>
set((state) => {
const found = state.items.find((i) => i.id === product.id);
if (found) {
return {
items: state.items.map((i) =>
i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...product, quantity: 1 }] };
}),
remove: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}));
import { useCartStore } from './useCartStore';
function CartCount() {
const count = useCartStore((state) => state.items.reduce((sum, i) => sum + i.quantity, 0));
return <span>Cart ({count})</span>;
}
function ProductCard({ product }) {
const add = useCartStore((state) => state.add);
return <button onClick={() => add(product)}>Add to cart</button>;
}
The store is a hook. Components pick what they need with a selector, and call the functions in the store directly. With Zustand you still update state the immutable way (a new array or object), unless you add its immer helper. There is no provider, so the setup is tiny.
Redux Toolkit or Zustand?
- Zustand: small, easy, fast to learn. A great choice for small and medium apps, and for a few shared values.
- Redux Toolkit: more structure, great devtools, and a large community and ecosystem. A good fit for large apps and big teams that value strict patterns.
- Context + useReducer: zero extra libraries, fine when state is small and changes rarely.
Neither is “better”. If you are unsure, start with the simplest one that works, because it is easy to move to a bigger tool later. The ideas you learned (actions, reducers, selectors, immutability) are the same.
Rules that work with any store
- Keep local state local. Do not put a text box value in the global store.
- Keep server data in a data library, not in your store (see lesson 25).
- Store the minimum, and calculate the rest with selectors.
- Select small pieces to avoid extra renders.
- Never put non-serialisable things (functions, class instances) in a Redux store.
Saving the store
To keep the cart after a reload, save it in localStorage. Zustand has a persist helper for this. In Redux you subscribe to the store or use a library such as redux-persist. Do not save sensitive data in localStorage.
Common mistakes
- Adding a library before you need it.
- Putting every little value in the global store.
- Selecting the whole state in
useSelector, which redraws the component on any change. - Changing the state outside
createSlicereducers in Redux, which breaks the rules. - Storing API data in the store and writing loading and error code by hand.
A decision in three questions
When you start a new feature and wonder whether you need a store, ask these questions in order.
- Is it data from a server? Use a data library. Do not copy it into a store.
- Does only one component, or one parent and child, use it? Use local state or props.
- Do many far-apart components change it, and do the rules take real effort? Now a store such as Zustand or Redux Toolkit is a good fit.
Notice that most data fails the first two tests. That is normal. A well-built app often has very little global client state: maybe the user, a cart, and a few settings. Keeping the store small makes the app easier to understand, and easier to move to a different tool if you ever need to.
Questions beginners often ask
Can I use Redux and TanStack Query together? Yes, and it is a common setup. TanStack Query handles server data, and Redux Toolkit or Zustand holds shared client state such as filters and the cart.
Will a store make my app faster? Not by itself. A store changes where state lives. It can reduce extra renders when you select small pieces, but it is not a speed tool.
Is Redux hard to learn? Redux Toolkit is much easier than the old Redux. If you understood useReducer in lesson 15, you already know the main idea of actions and reducers.
Do I need to wrap Zustand in a provider? No. A Zustand store is a hook you can import and use anywhere, which is part of why it is quick to start with.
Your task
- Build the cart with Redux Toolkit: slice, store, provider,
useSelector,useDispatch. - Build the same cart with Zustand. Compare how many files and lines each needed.
- Show the total price using a selector.
- Install the Redux DevTools extension and watch the actions as you click Add.
How to check it worked
- The cart count is the same in the header and the cart page, in both versions.
- In the Redux DevTools you can see each action.
- Only components that use changed data redraw.
Summary
- Use a state library only for shared client state that is complex. Keep local state local, and use a data library for server data.
- Redux Toolkit: slices,
configureStore,useSelectoranduseDispatch. Immer lets you write simple updates inside slices. - Zustand: a store as a hook with
create, no provider, tiny setup. - Select small pieces of state, store the minimum, and keep actions clear.