Лаборатория состояния: 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. Это не журнал сетевых эффектов.
Класс и getter без реактивности
Перед MobX отделим механизм языка от механизма библиотеки:
class PlainFilter {
constructor(query = "") {
this.query = query;
}
get label() {
return this.query === "" ? "Все события" : this.query;
}
}
const plain = new PlainFilter();
console.log(plain.label); // Все события
plain.query = "Урок";
console.log(plain.label); // Урок
class описывает создание объектов, new создаёт экземпляр, constructor
инициализирует его. this здесь обозначает экземпляр, а getter label вызывается
при чтении свойства без скобок. Значение пересчитывается при чтении; само изменение
query никого не уведомляет. Поле query = "" в следующем классе — другая форма
начального значения экземпляра. Наблюдение и кеширование computed добавляет MobX.
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.