← назад к разделу

Корзина нужна в шапке, на странице товара и при оформлении заказа. Передавать её через props на пять уровней неудобно, а context перерисовывает всех подписчиков на любое изменение. Тогда берут менеджер состояния: отдельное хранилище вне дерева компонентов, на которое компоненты подписываются.

Библиотек много, но устроены они по двум схемам. Redux хранит состояние неизменяемым: каждое действие создаёт новое состояние, а компонент перерисовывается, если изменилась нужная ему часть. MobX разрешает менять объекты напрямую и сам следит, кто какие поля читал. Ниже обе схемы изнутри, Redux Toolkit и RTK Query как современный Redux, и как выбрать. Когда стор вообще нужен, разобрано в статье про состояние.

Redux действие редьюсер:новое состояние селектор:старое === новое? нет: компонентперерисован MobX cart.count = 3прямо в объекте кто читал count?список запомнен перерисовантолько читатель

Redux сравнивает ссылки после каждого действия, MobX заранее знает, кто читал изменённое поле.

Redux: новое состояние вместо изменённогоспросят на собеседовании

В Redux состояние меняют только через действие: объект вида { type: "cart/added", payload: "чайник" }. Действие попадает в редьюсер — функцию, которая по старому состоянию и действию возвращает новое. Компонент подписывается через useSelector и перерисовывается, если выбранная им часть стала другой.

«Другой» здесь значит другую ссылку: сравнение идёт через ===, а не по содержимому. Если редьюсер поменял массив на месте, ссылка осталась прежней, и компонент изменения не увидит. Поэтому новое состояние собирают копированием, а нетронутые ветки переиспользуют:

живой пример

const state = { cart: { items: ["чайник"] }, user: { name: "Аня" } };

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, "кружка");
console.log("мутация, ссылка та же:", state.cart.items === before);

const fresh = { cart: { items: ["чайник"] }, user: { name: "Аня" } };
const next = addImmutable(fresh, "кружка");
console.log("корзина получила новую ссылку:", next.cart.items !== fresh.cart.items);
console.log("пользователь остался прежним:", next.user === fresh.user);
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Последняя строка важна: компонент, который читает только пользователя, после добавления в корзину не перерисуется.

Redux Toolkit: тот же Redux без шаблонного кода

Вручную копировать вложенные объекты утомительно, а на каждое действие раньше писали тип, функцию-создатель и ветку в switch. Redux Toolkit — официальный способ писать на Redux сегодня, и он убирает этот шаблонный код. createSlice по одному описанию делает редьюсер и создатели действий, а внутри работает библиотека Immer: в редьюсере можно писать push, и Immer соберёт из этого новое неизменяемое состояние.

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("кружка") возвращает готовое действие { type: "cart/added", payload: "кружка" }. configureStore сразу подключает инструменты разработчика и в режиме разработки замораживает состояние: случайная мутация вне редьюсера упадёт с ошибкой, а не потеряется молча. Работает это только внутри createSlice: в обычной функции push по-прежнему мутирует.

Селекторы и лишние перерисовкиспросят на собеседовании

Самая частая проблема с Redux — компонент перерисовывается на каждое действие, хотя его данные не менялись. Обычно виноват селектор, который каждый раз создаёт новый массив: filter и map всегда возвращают новую ссылку, и === считает результат изменившимся.

Так не надо:

const inStock = useSelector((state) => state.items.filter((item) => item.inStock));

Так надо: запомнить результат и пересчитывать его, только когда изменились входные данные. В Redux Toolkit для этого есть createSelector, а ниже та же идея в десяти строках:

живой пример

let calls = 0;
const state = { items: [{ name: "чайник", inStock: true }, { name: "кружка", 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("без памяти, ссылки равны:", selectInStock(state) === selectInStock(state));
const selectMemo = memoize(selectInStock);
console.log("с памятью, ссылки равны:", selectMemo(state) === selectMemo(state));
console.log("фильтр посчитан раз:", calls);
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

RTK Query: данные с сервера внутри Reduxспросят на собеседовании

Список товаров с сервера — не состояние приложения, а кеш: его нужно загрузить, показать загрузку, обновить после изменения. Писать для этого действия и редьюсеры вручную — сотни строк. RTK Query входит в Redux Toolkit и решает ту же задачу, что TanStack Query: описываете запросы, получаете готовые хуки с флагами загрузки и кешем.

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;

Теги связывают запросы: после addProduct всё, что помечено Product, загрузится заново. Если в проекте уже есть Redux, RTK Query естественнее, чем вторая библиотека. Если Redux нет, проще взять TanStack Query: он не требует стора.

MobX: меняем объект, интерфейс следит самспросят на собеседовании

MobX устроен наоборот. Состояние — обычные объекты и классы, их меняют напрямую: cart.count = 3. Компонент, обёрнутый в observer, во время отрисовки запоминает, какие поля он прочитал, и перерисовывается только при их изменении. Селекторы и сравнение ссылок не нужны.

Механика держится на перехвате чтений. Вот она в упрощённом виде, на Proxy:

живой пример

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("в корзине:", cart.count));
cart.count = 2;
cart.promo = "SALE";
cart.count = 3;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Изменение promo реакцию не запустило: её функция это поле не читала. В настоящем MobX то же самое делают makeAutoObservable и observer, а геттеры становятся вычисляемыми значениями с кешем:

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>);

Главная грабля MobX — забыть observer: компонент отрисуется один раз и больше не обновится. Вторая — прочитать поле вне отрисовки, например сохранить cart.count в переменную в начале модуля: такое чтение никто не отслеживает.

Как выбрать

Для нового проекта на React вопрос обычно решается так. Данные с сервера — TanStack Query или RTK Query, это не задача стора. Немного глобального клиентского состояния — Zustand: меньше всего кода. Большое приложение с командой и строгими правилами — Redux Toolkit: явные действия, история изменений в инструментах разработчика, предсказуемость. Сложная предметная модель с вычисляемыми полями, где удобно думать объектами, — MobX.

Коротко

  • Redux: состояние неизменяемо, компонент перерисовывается, когда === видит новую ссылку.
  • Мутация в обычном редьюсере не видна подписчикам; в createSlice её превращает в копию Immer.
  • filter и map в селекторе дают новую ссылку и лишние перерисовки — нужен createSelector.
  • RTK Query — кеш серверных данных внутри Redux; теги providesTags и invalidatesTags перезагружают связанные запросы.
  • MobX: объекты меняют напрямую, observer следит за прочитанными полями; без observer компонент не обновится.
  • Сервер — TanStack Query или RTK Query, мало клиентского состояния — Zustand, большая команда — Redux Toolkit, сложная модель — MobX.

Что почитать дальше