Global State in React: The Limits of Context, Zustand and When to Use Redux Toolkit
Most "global state" is really server, URL or form state; for the small client state that is truly global, Zustand's store with selectors fixes Context's render-every-reader problem, while Redux Toolkit comes in when you need an action log and rules for a large team. In Bookstore we move the recently viewed books and the cart id into a persisted store, and show reading without selectors causing 13 renders instead of 1 and a selector returning a new object looping forever in Zustand 5.
Version note: The examples in this post were built and run with React 19.3, Zustand 5.1, Redux Toolkit 2.14, React Router 8.5, TanStack Query 5.107, TypeScript 7.0, Vite 8.3 and Node.js 24; API calls went through the Vite dev proxy to a local copy of Bookstore.Api (ASP.NET Core 10), and render counts and localStorage content were observed with Playwright 1.65 in headless Chromium.
Predict First
In the React client of Bookstore, the online bookstore used as the shared example across this site's series, state has spread out to different places over the last few posts. In this post we add a "Recently viewed" box to the catalog page: the last five books the user opened, kept even when the page is reloaded. This list and the id of the server-side cart live in the same Zustand store.
Under the box there is a "Clear history" button. How many components re-render when it is clicked? There is no reason to render the cart summary, the catalog page or the book cards; the cart was not touched. The answer depends on how you read the store, and the difference between the two ways of reading it is 1 render versus 13.
Classify First: Is This State Really Global?
When people say "global state", most teams picture one big store. Looking at the state that has built up in Bookstore so far by kind, the picture is different:
- Server state: books, book details, the cart's content, orders. The truth lives on the server; the client only has a cache of it. In TanStack Query since the eighth post.
- URL state: the search text and the page number. It must be shareable and reachable with the back button. In the query string since the ninth post.
- Form state: the order form's fields and errors. It is born and dies with the form. In react-hook-form since the tenth post.
- Local UI state: the amount picked on a card, the gift note. It belongs to one component;
useState. - Client state: theme preference, the signed-in user, the cart's id, the recently viewed books. Not on the server, read by several screens.
Only the last item really needs "global state management", and it is small. The first post of the Angular State Management series, "Angular'da State Nedir ve Nerede Yaşamalı? Server, UI, URL ve Form State'ini Ayırmak", started with the same split; the framework changes, the question stays. Copying server state into a global store would be the most expensive mistake in this series: everything TanStack Query solves, such as invalidation, retries and cache lifetime, would have to be rewritten in the store.
The Limit of Context
In the seventh post we put the theme, the user and the cart into three separate Contexts and measured this: every component that reads a Context renders when the Context's value changes; there is no way to say "watch only this field" with Context. For values that rarely change (theme, language) this is fine. For a value that changes often and whose different parts are read by different components, there are two options: keep splitting the Context into smaller pieces, or use a store that offers selective subscription.
The ninth post added another reason: the router's middleware and loaders run outside the React tree and cannot read a Context. That is why we moved the user and the cart id into two small hand-made stores read with useSyncExternalStore. Those two files worked, but they kept their subscribers by hand and asked us to write things like selective subscription and writing to localStorage again for each one. Zustand is a small library that fills exactly this gap.
A Zustand Store and Selectors
npm install zustand@5.1.0
import { create } from 'zustand';
export type User = { name: string; email: string };
type AuthState = {
user: User | null;
signIn: (user: User) => void;
signOut: () => void;
};
// Demo sign-in, in memory only. Components select from it; router middleware reads useAuthStore.getState()
export const useAuthStore = create<AuthState>()((set) => ({
user: null,
signIn: (user) => set({ user }),
signOut: () => set({ user: null }),
}));
create returns a hook: useAuthStore. The store is defined at module level, with no provider; it is closer to a service with providedIn: 'root' in Angular. Zustand connects to React through useSyncExternalStore, which we saw in the seventh post. Components read the store through a selector function:
export function UserMenu() {
const user = useAuthStore((state) => state.user);
const signOut = useAuthStore((state) => state.signOut);
As long as the value returned by the selector does not change, the component does not render; the comparison uses Object.is. To read from outside React there is getState(); the sign-in check middleware from the ninth post now looks like this:
// Runs before the route's loader/action on every navigation to it: the counterpart of a CanActivate guard
export const requireUser: MiddlewareFunction = ({ request, context }) => {
const user = useAuthStore.getState().user;
The cart id and the recently viewed books, however, must survive a reload. For that, a second store and the persist middleware:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type RecentBook = { isbn: string; title: string };
type ClientState = {
cartId: string | null;
recentlyViewed: RecentBook[];
setCartId: (cartId: string | null) => void;
addRecentlyViewed: (book: RecentBook) => void;
clearRecentlyViewed: () => void;
};
// Client state that should survive a reload: which server cart is ours, and the last books opened
export const useClientStore = create<ClientState>()(
persist(
(set) => ({
cartId: null,
recentlyViewed: [],
setCartId: (cartId) => set({ cartId }),
addRecentlyViewed: (book) =>
set((state) => ({
recentlyViewed: [book, ...state.recentlyViewed.filter((b) => b.isbn !== book.isbn)].slice(0, 5),
})),
clearRecentlyViewed: () => set({ recentlyViewed: [] }),
}),
{
name: 'bookstore.client', // localStorage key
version: 1,
// Persist data only; the functions are recreated by the store
partialize: (state) => ({ cartId: state.cartId, recentlyViewed: state.recentlyViewed }),
},
),
);
partialize writes only the data to localStorage; the functions are recreated on every load. version makes it possible to transform old records with a migrate function when the store's shape changes. The book page's loader adds to the list on every visit; since the loader runs outside React, it also uses getState():
// Like a resolver: the route renders only after the book is in the cache
export async function bookLoader({ params }: LoaderFunctionArgs) {
try {
const book = await queryClient.ensureQueryData(bookQuery(params.isbn!));
// Loaders run outside React: getState() reaches the store without a hook
useClientStore.getState().addRecentlyViewed({ isbn: book.isbn, title: book.title });
On the reading side there is a small hook with two selectors and the component that shows the list:
import { useClientStore } from '@/shared/clientStore';
export function useCartId() {
// Two selectors: this component re-renders only when cartId changes, not on every store update
const cartId = useClientStore((state) => state.cartId);
const setCartId = useClientStore((state) => state.setCartId);
return { cartId, setCartId };
}
import { Link } from 'react-router';
import { useClientStore } from '@/shared/clientStore';
export function RecentlyViewed() {
const recent = useClientStore((state) => state.recentlyViewed);
const clear = useClientStore((state) => state.clearRecentlyViewed);
if (recent.length === 0) {
return null;
}
return (
<aside className="recent" aria-label="Recently viewed">
<h2>Recently viewed</h2>
<ul>
{recent.map((book) => <li key={book.isbn}><Link to={`/books/${book.isbn}`}>{book.title}</Link></li>)}
</ul>
<button type="button" className="link" onClick={clear}>Clear history</button>
</aside>
);
}
We opened Dune and Emma, went back to the catalog, added Dune to the cart and reloaded the page:
first visit | recently viewed: (none) | localStorage: null
after browsing | recently viewed: Emma, Dune | Cart: 1 item(s) · ₺489.00
localStorage: {"state":{"cartId":"{cartId}","recentlyViewed":[{"isbn":"9780141439587","title":"Emma"},{"isbn":"9780441172719","title":"Dune"}]},"version":1}
after reload | recently viewed: Emma, Dune | Cart: 1 item(s) · ₺489.00
On the first visit localStorage had no entry. After browsing, the bookstore.client key holds only the two fields picked by partialize and the version number. After the reload both the list and the cart were in place: Zustand reads localStorage synchronously, so the data was ready on the first render and the cart query ran with the right id.
Measuring Selective Subscription
We measured the Predict First question in a production build with two versions. The first is the snapshot code: useCartId reads the store with two selectors. The second is the same hook without selectors:
export function useCartId() {
// WRONG: no selector, subscribes to the whole store like a Context would
const { cartId, setCartId } = useClientStore();
return { cartId, setCartId };
}
selectors: "Clear history": RecentlyViewed
no selector: "Clear history": CartSummary, CatalogPage×2, RecentlyViewed, BookList, BookCard×8
In the version with selectors only RecentlyViewed, which shows the list, rendered. In the version without selectors useClientStore() subscribed to the whole store; when any field of the store changed, every component that calls useCart (the cart summary, the catalog page) rendered, along with the list and the eight cards below them, 13 renders in total. That is exactly Context's behavior. Using Zustand does not bring performance on its own; the gain comes from keeping selectors narrow.
Returning a New Object from a Selector, and useShallow
Returning both values in one object instead of writing two selectors looks natural:
export function useCartId() {
// WRONG: the selector builds a new object on every call
return useClientStore((state) => ({ cartId: state.cartId, setCartId: state.setCartId }));
}
console.error: The result of getSnapshot should be cached to avoid an infinite loop
console.error: React Router caught the following error during render Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
catalog on screen: false
The app never opened. The selector returns a new object on every call; when useSyncExternalStore sees a different reference on two reads in a row it assumes the store has changed and triggers a render, and the render produces a new object. React first warned that the snapshot should be cached, then stopped rendering with "Maximum update depth exceeded", and React Router's error boundary caught the error. Zustand 5's migration guide states plainly that a selector returning a new reference can cause an infinite loop and shows useShallow as the fix:
import { useShallow } from 'zustand/shallow';
import { useClientStore } from '@/shared/clientStore';
export function useCartId() {
// useShallow compares the fields of the new object with the previous one and keeps the old reference if they match
return useClientStore(useShallow((state) => ({ cartId: state.cartId, setCartId: state.setCartId })));
}
catalog on screen: true
useShallow compares the fields of the new object with the previous one one by one and returns the old reference if they match. In the snapshot we preferred two separate selectors; both are correct, the first needs one import and one concept less.
Where Redux Toolkit Fits
Redux is the most widespread implementation of the Flux architecture; it is the very one-way flow we covered in the UI Architecture Patterns series post "Flux ve Unidirectional Data Flow: Two-Way Binding Neden Ölçeklenmedi?". Redux Toolkit is the recommended way to write Redux today. We wrote the RTK counterpart of the "Recently viewed" list as a small Node script and ran it; we did not add it to the app:
import { configureStore, createSlice, type Middleware, type PayloadAction } from '@reduxjs/toolkit';
type RecentBook = { isbn: string; title: string };
// A slice: state, reducers and generated action creators in one place
const recentSlice = createSlice({
name: 'recent',
initialState: [] as RecentBook[],
reducers: {
viewed(state, action: PayloadAction<RecentBook>) {
// Immer: code that looks like mutation produces a new immutable state
const index = state.findIndex((b) => b.isbn === action.payload.isbn);
if (index >= 0) {
state.splice(index, 1);
}
state.unshift(action.payload);
state.splice(5);
},
cleared: () => [],
},
});
export const { viewed, cleared } = recentSlice.actions;
// Every change passes through dispatch as a named action: easy to log, replay and inspect in DevTools
const actionLog: string[] = [];
const logger: Middleware = () => (next) => (action) => {
actionLog.push(JSON.stringify(action));
return next(action);
};
const store = configureStore({
reducer: { recent: recentSlice.reducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});
store.dispatch(viewed({ isbn: '9780441172719', title: 'Dune' }));
store.dispatch(viewed({ isbn: '9780141439587', title: 'Emma' }));
store.dispatch(viewed({ isbn: '9780441172719', title: 'Dune' }));
console.log('state:', store.getState().recent.map((b) => b.title).join(', '));
console.log(actionLog.join('\n'));
$ node recentSlice.ts
state: Dune, Emma
{"type":"recent/viewed","payload":{"isbn":"9780441172719","title":"Dune"}}
{"type":"recent/viewed","payload":{"isbn":"9780141439587","title":"Emma"}}
{"type":"recent/viewed","payload":{"isbn":"9780441172719","title":"Dune"}}
The difference from Zustand is in the second part of the output. Every change goes through dispatch as a named action; that flow can be logged, rewound step by step in Redux DevTools, and attached to a bug report as "the user did these actions in this order". The price is more concepts: slice, action, reducer, store, middleware and a provider on the React side. RTK Query does inside the Redux store what we did with TanStack Query in the eighth post.
This pair is familiar on the Angular side. Zustand's plain store maps to NgRx Signal Store or a service holding signals, and RTK maps to the classic NgRx Store (actions, reducers, selectors, effects). The last post of the Angular State Management series, "Doğru State Aracını Seçmek ve Angular State'in Evrimi: BehaviorSubject'ten Signal Store'a", answers the same question for Angular.
A Decision Table
| State | First choice | When to move to the next one |
|---|---|---|
| Belongs to one component | useState / useReducer | When a sibling component starts reading it |
| A few nearby components | Lift it up to the common parent | When props start going down more than three or four levels |
| Rarely changes, read in many places (theme, language) | Context | When it starts changing often, or must be read from outside React |
| Data from the server | TanStack Query (or a router loader) | Never copy it into a global store |
| A shareable view (search, filter, page) | URL search params | When it is secret or very large |
| Form fields | Form actions or react-hook-form | End the state when the form ends |
| Client state that changes often and is read in many places | Zustand (with selectors) | When you need change history, an action log and rules for a large team |
| Large, multi-team app where the flow of events matters | Redux Toolkit | - |
Read the table from top to bottom: when the tool in one row is not enough, go one row down. Most apps stay in the middle rows; all of Bookstore's global client state is two stores of about ten lines each.
In Production
Everything you write to localStorage stays in that browser, unencrypted, where every script on the same origin can read it. That is acceptable for a cart id and recently viewed books; not for tokens, personal data or permissions. Why localStorage is a weak place against XSS, and the BFF approach as the alternative, are explained in detail in the Frontend Security series post "Token Nerede Saklanmalı? localStorage, Memory, Cookie, Service Worker ve BFF'in Tehdit Modeli".
Remember that the persisted shape is a contract. When you rename a field, old records are loaded into new code; bump version and write a migrate, or deliberately ignore the old record. Our change is such a migration too: the bookstore.cartId key from the previous posts is no longer read, so for a user moving to this version the cart is "forgotten" in the browser (it stays on the server). A real app would need that one-time move written.
Since stores live at module level, state carries over between tests. You need to reset the store to its initial state before every test. We will come back to this when we write tests in the thirteenth post. For the same reason, with server-side rendering a module-level store is shared between all users; with Next.js in the fifteenth post the store has to be created per request.
Common Pitfalls
Reading the store without a selector: const { a, b } = useStore() subscribes to the whole store and brings back Context's problem.
Returning a new object or array from a selector: in Zustand 5 this is an infinite loop. Write separate selectors or use useShallow. Doing filter or map inside a selector also produces a new array every time; do it outside the selector, during render.
Putting server state into the store: writing the book list into Zustand and feeding screens from there means a second copy next to the TanStack Query cache. Invalidation updates one, the other stays old.
Gathering everything into one store: having the theme, the user and the cart id in the same store is not a technical problem (selectors protect you), but it mixes different lifecycles: one is persisted and one is not; one is cleared on sign-out and one is not. Small, purpose-built stores are easier to read.
A Small Experiment
★☆☆ Move the theme preference (choice in ThemeProvider) into useClientStore and persist it. Is the chosen theme kept after a reload? Can you remove the Context entirely, or does it still make sense to keep one somewhere?
★★☆ Write a flow that clears the cart id and the recently viewed books on sign-out. Where should this logic live: inside signOut, in a component, or between the stores with Zustand's subscribe?
★★★ Set clientStore's version to 2 and add the book's author to recentlyViewed. Write a migrate function that leaves the author empty for old records, and add one-time code that moves the bookstore.cartId key from the previous posts into the new store. Try it with Playwright by writing an old record into localStorage and opening the page.
We clarified where state lives and prevented some needless renders with selectors. But Bookstore still has renders we have not measured: on every add to cart all eight book cards render. In the next post we measure with the Profiler whether these renders really cost anything, see when memo, useMemo and useCallback help, and turn on the React Compiler to repeat the same measurement.