← back to section

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 action reducer:new state selector:old === new? no: componentre-renders MobX cart.count = 3right in the object who read count?readers are tracked re-rendersonly the reader

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 createSlice Immer turns it into a copy.
  • filter and map in a selector create a new reference and extra re-renders: use createSelector.
  • RTK Query is a server-data cache inside Redux; providesTags and invalidatesTags refetch related queries.
  • MobX: mutate objects directly, observer tracks the fields that were read; without observer the component never updates.
  • Server data: TanStack Query or RTK Query; little client state: Zustand; big team: Redux Toolkit; complex model: MobX.

Further reading