Учебник веб-разработки
Разделы учебника
На этой странице

IV. React и состояние

Лаборатория состояния: reducer, MobX, атомы и сигналы

Оглавление · Flux · FRP

Сравним один фильтр событий, сохранив данные, функцию отбора и последовательность действий. Проверяем результат и механизм уведомления отдельно: одинаковый список не означает одинаковую модель подписок. MobX здесь — настоящая библиотека; атом и сигнал — небольшие учебные реализации, без обещаний production-совместимости. Ни одна из них не добавляется в зависимости Atmanki.

Общий контракт и reducer

Сохраните каждый JS-блок этой главы по порядку в один comparison.mjs. Создайте для него отдельный временный каталог, вне workspace; там выполните pnpm add --save-exact mobx@7.0.6. Это версия лаборатории, а не изменение стека. Затем запустите node comparison.mjs.

import assert from "node:assert/strict";
import { autorun, makeAutoObservable } from "mobx";

const entries = [
  { id: 1, title: "Урок", location: "Площадь" },
  { id: 2, title: "Тренировка", location: "Шатёр" },
  { id: 3, title: "Открытый урок", location: "Шатёр" },
];
const initial = { query: "", location: "" };
function select(filter) {
  const query = filter.query.trim().toLocaleLowerCase("ru");
  return entries
    .filter(
      (entry) =>
        (!filter.location || entry.location === filter.location) &&
        entry.title.toLocaleLowerCase("ru").includes(query),
    )
    .map((entry) => entry.id);
}
const actions = [
  { type: "query", value: "урок" },
  { type: "location", value: "Шатёр" },
  { type: "reset" },
];
function reducer(state, action) {
  switch (action.type) {
    case "query":
      return { ...state, query: action.value };
    case "location":
      return { ...state, location: action.value };
    case "reset":
      return { ...initial };
    default:
      throw new Error("Unknown action");
  }
}
const expected = [[1, 2, 3], [1, 3], [3], [1, 2, 3]];
let state = initial;
const fluxResults = [select(state)];
for (const action of actions) {
  state = reducer(state, action);
  fluxResults.push(select(state));
}
assert.deepEqual(fluxResults, expected);

Reducer явно задаёт переход S × A → S. Уведомление React обеспечивает useReducer; Context доставляет значение, но сам не делает отдельные поля селективно реактивными. Историю действий можно воспроизвести при том же исходном входе и чистом reducer. Это не журнал сетевых эффектов.

Observable-граф MobX

class Filter {
  query = "";
  location = "";
  constructor() {
    makeAutoObservable(this);
  }
  get visible() {
    return select(this);
  }
  apply(action) {
    if (action.type === "reset") {
      this.query = "";
      this.location = "";
    } else if (action.type === "query") this.query = action.value;
    else if (action.type === "location") this.location = action.value;
    else throw new Error("Unknown action");
  }
}
const filter = new Filter();
const mobxResults = [];
const dispose = autorun(() => mobxResults.push(filter.visible));
for (const action of actions) filter.apply(action);
dispose();
filter.apply({ type: "query", value: "не наблюдается" });
assert.deepEqual(mobxResults, expected);

makeAutoObservable делает поля наблюдаемыми, getter — computed, методы — autoAction. autorun отслеживает чтения, выполненные синхронно внутри него. После action наблюдатель видит согласованное состояние: reset меняет два поля, но не публикует промежуточный фильтр. После await нужен новый action, например runInAction; исходный action не охватывает продолжение Promise. dispose завершает подписку, иначе она продолжает удерживать данные.

Для React используется observer из React-интеграции MobX: компонент подписывается на прочитанные observable. Вынесенное раньше обычное число не превращается в реактивное prop само по себе. Здесь проверяется ядро MobX, не browser-render и не подключение mobx-react-lite к проекту.

Атом: явное чтение и подписка

function atom(initialValue) {
  let value = initialValue;
  const listeners = new Set();
  return {
    get: () => value,
    set(next) {
      if (Object.is(value, next)) return;
      value = next;
      for (const listener of [...listeners]) listener();
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}
const filterAtom = atom(initial);
const atomResults = [select(filterAtom.get())];
const unsubscribe = filterAtom.subscribe(() => atomResults.push(select(filterAtom.get())));
for (const action of actions) filterAtom.set(reducer(filterAtom.get(), action));
unsubscribe();
assert.deepEqual(atomResults, expected);

Атом — самостоятельная ячейка. Зависимость производного здесь прописана вручную в subscribe. Настоящие атомные библиотеки добавляют граф derived atoms, scopes, async и интеграцию React. Для внешнего store React предоставляет useSyncExternalStore: subscribe должен возвращать cleanup, snapshot быть стабильным между изменениями; для SSR нужен согласованный server snapshot. Не вызывайте setter во время render.

Сигнал: отслеживание чтения

Добавим учебному атому tracked read. Для простоты эффект имеет только один источник; динамические зависимости, batch, ошибки и вложенные эффекты не поддержаны.

let collecting = null;
function signal(initialValue) {
  const cell = atom(initialValue);
  return {
    read() {
      if (collecting) collecting(cell);
      return cell.get();
    },
    write: cell.set,
  };
}
function effect(run) {
  let stop = () => {};
  collecting = (cell) => {
    stop = cell.subscribe(run);
  };
  try {
    run();
  } finally {
    collecting = null;
  }
  return () => stop();
}
const filterSignal = signal(initial);
const signalResults = [];
const stop = effect(() => signalResults.push(select(filterSignal.read())));
for (const action of actions) filterSignal.write(reducer(filterSignal.read(), action));
stop();
assert.deepEqual(signalResults, expected);
console.log("Four models:", JSON.stringify(expected));

Здесь подписка возникает из чтения; в атомном варианте она была явной. Запись обоих полей одним объектом обходит проблему промежуточного снимка, но не доказывает наличие транзакций в произвольной signal-библиотеке. Сигналы не меняют правил React автоматически: важна конкретная интеграция.

Исходник схемы
flowchart LR
  Action[Действие] --> Reducer[Явный переход]
  Action --> Observable[Action и observable]
  Action --> Atom[Запись атома]
  Action --> Signal[Запись сигнала]
  Reducer --> Result[Одинаковый список]
  Observable --> Result
  Atom --> Result
  Signal --> Result

Где должно жить состояние

ДанныеВладелецЧто происходит при возврате назад или обновлении
Подтверждённый фильтр поискаURL queryВосстанавливается из URL; значения проверяются
Текст до отправки формыЛокальный draftНе обязан сразу менять URL и сетевой запрос
Редакторский контентCMSКлиентский store не заменяет сохранение в CMS
Снимок ответа CMSСерверный кешТребует TTL/инвалидирования, не является источником истины
Несохранённая работаЯвно выбранное persistenceНужны версия формата, ошибки записи и политика восстановления

URL — сериализуемое представление подтверждённых параметров. При Back/Forward читайте его снова; не держите независимые «истины» URL и store. При фильтрации на сервере не отфильтровывайте только одну загруженную страницу и не называйте результат полным. localStorage существует в браузере, синхронен и может отказать; чтение в SSR невозможно, восстановление может менять первый клиентский снимок. Хранимые значения тоже требуют runtime-валидации и миграции версии.

Приёмка: все четыре модели дают expected; повтор reset сохраняет результат, после cleanup уведомления прекращаются. Дополните опыт двумя отдельными атомами и покажите промежуточное состояние при reset без batch. Объясните, какие изменения уведомляют подписчика и кто владеет фильтром. Для одного локального фильтра React state/reducer обычно достаточно; дополнительную библиотеку обосновывает задача.

Источники: observable, reactions, внешний store React.