The cart is needed in the header, on the product page and at checkout. Passing it through props five levels deep is awkward, and context re-renders every subscriber on any change. That is when a state manager comes in: a store outside the component tree that components subscribe to.
There are many libraries, but they follow two models. Redux keeps state immutable: every action produces a new state, and a component re-renders when the part it needs has changed. MobX lets you mutate objects directly and tracks who read which fields. Below are both models from the inside, Redux Toolkit and RTK Query as modern Redux, and how to choose. When a store is needed at all is covered in the article on state.
Redux compares references after every action; MobX already knows who read the changed field.
Redux: a new state instead of a changed one
In Redux, state changes only through an action: an object like { type: "cart/added", payload: "kettle" }. The action goes to a reducer, a function that takes the old state and the action and returns a new state. A component subscribes with useSelector and re-renders if the slice it selected has become different.
"Different" means a different reference: the comparison is ===, not a deep check. If a reducer changed an array in place, the reference stays the same and the component will not see the change. So a new state is built by copying, and untouched branches are reused:
live example
const state = { cart: { items: ["kettle"] }, user: { name: "Anna" } };
function addMutating(s, item) {
s.cart.items.push(item);
return s;
}
function addImmutable(s, item) {
return { ...s, cart: { ...s.cart, items: [...s.cart.items, item] } };
}
const before = state.cart.items;
addMutating(state, "mug");
console.log("mutation, same reference:", state.cart.items === before);
const fresh = { cart: { items: ["kettle"] }, user: { name: "Anna" } };
const next = addImmutable(fresh, "mug");
console.log("cart got a new reference:", next.cart.items !== fresh.cart.items);
console.log("user stayed the same:", next.user === fresh.user);
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The last line matters: a component that reads only the user will not re-render after something is added to the cart.
Redux Toolkit: the same Redux without boilerplate
Copying nested objects by hand is tedious, and every action used to need a type, an action creator and a switch branch. Redux Toolkit is the official way to write Redux today, and it removes that boilerplate. createSlice builds the reducer and action creators from one description, and inside it the Immer library lets you write push in a reducer while it produces a new immutable state.
import { createSlice, configureStore } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: { items: [] },
reducers: {
added(state, action) {
state.items.push(action.payload);
},
removed(state, action) {
state.items = state.items.filter((item) => item !== action.payload);
},
},
});
export const { added, removed } = cartSlice.actions;
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
added("mug") returns a ready action { type: "cart/added", payload: "mug" }. configureStore wires up the developer tools and freezes state in development, so an accidental mutation outside a reducer throws instead of being lost silently. This only works inside createSlice: in a plain function push still mutates.
Selectors and extra re-renders
The most common Redux problem is a component that re-renders on every action even though its data did not change. Usually the selector is to blame: it creates a new array each time. filter and map always return a new reference, and === treats the result as changed.
Don't:
const inStock = useSelector((state) => state.items.filter((item) => item.inStock));
Do: remember the result and recompute it only when the inputs change. Redux Toolkit has createSelector for this; here is the same idea in ten lines:
live example
let calls = 0;
const state = { items: [{ name: "kettle", inStock: true }, { name: "mug", inStock: false }] };
const selectInStock = (s) => s.items.filter((item) => item.inStock);
function memoize(selector) {
let lastInput;
let lastResult;
return (s) => {
if (s.items !== lastInput) {
calls++;
lastInput = s.items;
lastResult = selector(s);
}
return lastResult;
};
}
console.log("without memo, same reference:", selectInStock(state) === selectInStock(state));
const selectMemo = memoize(selectInStock);
console.log("with memo, same reference:", selectMemo(state) === selectMemo(state));
console.log("filter ran times:", calls);
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
RTK Query: server data inside Redux
A product list from the server is not application state but a cache: it has to be loaded, shown while loading and refreshed after changes. Writing actions and reducers for that by hand takes hundreds of lines. RTK Query ships with Redux Toolkit and solves the same problem as TanStack Query: you describe requests and get ready hooks with loading flags and a cache.
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const shopApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Product"],
endpoints: (build) => ({
getProducts: build.query({ query: () => "products", providesTags: ["Product"] }),
addProduct: build.mutation({
query: (product) => ({ url: "products", method: "POST", body: product }),
invalidatesTags: ["Product"],
}),
}),
});
export const { useGetProductsQuery, useAddProductMutation } = shopApi;
Tags link requests: after addProduct, everything tagged Product is fetched again. If the project already uses Redux, RTK Query fits more naturally than a second library. Without Redux, TanStack Query is simpler because it needs no store.
MobX: mutate the object, the UI keeps track
MobX works the other way round. State is plain objects and classes, and you change them directly: cart.count = 3. A component wrapped in observer remembers which fields it read during render and re-renders only when they change. No selectors, no reference comparison.
The mechanism rests on intercepting reads. Here it is in simplified form, with a Proxy:
live example
let running = null;
function observable(target) {
const readers = new Map();
return new Proxy(target, {
get(obj, key) {
if (running) {
if (!readers.has(key)) readers.set(key, new Set());
readers.get(key).add(running);
}
return obj[key];
},
set(obj, key, value) {
obj[key] = value;
for (const reaction of readers.get(key) ?? []) reaction();
return true;
},
});
}
function autorun(fn) {
const reaction = () => {
running = reaction;
fn();
running = null;
};
reaction();
}
const cart = observable({ count: 0, promo: "" });
autorun(() => console.log("in cart:", cart.count));
cart.count = 2;
cart.promo = "SALE";
cart.count = 3;
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Changing promo did not run the reaction because its function never read that field. Real MobX does the same with makeAutoObservable and observer, and getters become cached computed values:
import { makeAutoObservable } from "mobx";
import { observer } from "mobx-react-lite";
class CartStore {
items = [];
constructor() {
makeAutoObservable(this);
}
add(item) {
this.items.push(item);
}
get count() {
return this.items.length;
}
}
export const cart = new CartStore();
export const CartBadge = observer(() => <span>{cart.count}</span>);
The main MobX pitfall is forgetting observer: the component renders once and never updates. The second is reading a field outside render, for example saving cart.count to a variable at module load: nobody tracks that read.
How to choose
For a new React project the choice usually goes like this. Server data: TanStack Query or RTK Query, not a store's job. A little global client state: Zustand, the least code. A large app with a team and strict conventions: Redux Toolkit, with explicit actions, change history in the dev tools and predictability. A complex domain model with computed fields where thinking in objects is natural: MobX.
Summary
- Redux: state is immutable, a component re-renders when
===sees a new reference. - A mutation in a plain reducer is invisible to subscribers; in
createSliceImmer turns it into a copy. filterandmapin a selector create a new reference and extra re-renders: usecreateSelector.- RTK Query is a server-data cache inside Redux;
providesTagsandinvalidatesTagsrefetch related queries. - MobX: mutate objects directly,
observertracks the fields that were read; withoutobserverthe component never updates. - Server data: TanStack Query or RTK Query; little client state: Zustand; big team: Redux Toolkit; complex model: MobX.
Further reading
- State: local and server — when a store is not needed and
useStatewith context is enough. - Data fetching: TanStack Query — server-data cache without Redux.
- React rendering model — what triggers a re-render and how comparison works.
- Performance —
memo,useMemoand finding extra re-renders.