* Лавки на площади перед Почтамтом выставляют в витрине не товар, а таблички. Табличку «есть» пишут утром, табличку «кончилось» к вечеру забывают. Самая честная лавка на площади та, где хозяйка переворачивает табличку, пока покупатель ещё не дошёл до двери.
После урока 102 книга башен отвечает честно: открыта, закрыта или «не дозвонилась». Почтамт знает правду. Но горожанин видит не Почтамт, а витрину Чонси, и правда должна дойти до стекла раньше, чем человек дойдёт до двери.
Беда меловой таблички не в том, что её писали неправильно. Она была верна в семь. Беда в том, что табличка не знает, от чего зависит. Её никто не перепишет, когда закроют T-3, потому что между башней и мелом нет связи. Витрине нужны три вещи: значение, которое можно поменять; знание, кто от этого значения зависит; и честное состояние «ещё спрашиваю», чтобы пустое место не выглядело как ответ.
Всё это в Effect лежит в модуле effect/reactivity. Главное слово в нём атом.
Атом это значение с подписчиками
Атом похож на Ref из урока 65: в нём лежит значение, его можно прочитать и заменить. Разница в том, что атом сам по себе ничего не хранит. Значения атомов живут в реестре, а атом это описание: что лежит в ячейке и как это посчитать.
import { Atom, AtomRegistry } from 'effect/reactivity';
const selected = Atom.make('T-1');
const registry = AtomRegistry.make();
console.log(registry.get(selected)); // 'T-1'
registry.set(selected, 'T-3');
const stop = registry.subscribe(selected, (code) => console.log('выбрали', code));
registry.set(selected, 'T-2'); // выбрали T-2
stop();
Atom.make('T-1') описывает ячейку с начальным значением. AtomRegistry.make() создаёт реестр. get читает, set пишет, subscribe вешает подписчика и отдаёт функцию отписки. Подписчик узнал о T-2, потому что был подписан, когда значение поменялось. О T-3 он не узнал: подписался позже.
Почему атом отдельно от реестра? По той же причине, что сервис отдельно от слоя в уроке 48: описание одно, а мест, где оно живёт, может быть много. Две вкладки витрины, два теста, сервер и браузер берут один и тот же selected, а значения у каждого свои.
Производный атом пересчитывается сам
Табличку на витрине не пишут руками. Её считают из того, что выбрано:
const heading = Atom.make((get) => `Витрина: ${get(selected)}`);
console.log(registry.get(heading)); // 'Витрина: T-2'
Если Atom.make получил функцию, это производный атом. Внутри функции get читает другие атомы, и реестр запоминает, какие именно. Поменяли selected, и heading посчитается заново: сразу, если его кто-то держит или слушает, а без читателей при следующем чтении. Подписчики heading узнают об этом так же, как подписчики selected. Связь между башней и стеклом, которой не было у мела, теперь есть: она записана в самом get.
Атом из эффекта: «ещё спрашиваю» тоже ответ
Открыта ли башня, знает книга башен, а книга это эффект. Спросить её из атома помогает рантайм атомов:
import { Context, Effect, Layer } from 'effect';
import { AsyncResult, Atom } from 'effect/reactivity';
class TowerStatus extends Context.Service<
TowerStatus,
{ readonly open: (code: string) => Effect.Effect<boolean, string> }
>()('vitrina/TowerStatus') {}
const runtime = Atom.runtime(
Layer.succeed(TowerStatus, TowerStatus.of({ open: (code) => Effect.succeed(code !== 'T-3') })),
);
const bridge = runtime.atom(Effect.flatMap(TowerStatus, (book) => book.open('T-2')));
// ^? Atom<AsyncResult<boolean, string>>
Atom.runtime(layer) строит рантайм из слоя, как ManagedRuntime в уроке 58: сервисы для эффектов атомов берутся из этого слоя. runtime.atom(effect) делает атом, значение которого это ответ эффекта. Но ответа нет сразу: книге нужно время. Поэтому значение атома не boolean, а AsyncResult: сначала Initial, потом Success с ответом или Failure с бедой.
const text = AsyncResult.match(registry.get(bridge), {
onInitial: () => 'спрашиваю книгу',
onFailure: () => 'книга молчит',
onSuccess: ({ value }) => (value ? 'открыта' : 'закрыта'),
});
AsyncResult.match заставляет назвать все три состояния. Это и есть третья вещь из списка Чонси: «спрашиваю книгу» на стекле честнее, чем пустое место, и честнее, чем вчерашнее «открыта». Эту ошибку Почтамт делал до урока 102: не знал ответа и показывал вместо него что-то похожее на ответ.
Семья атомов: одна башня, один вопрос
Башен в городе сорок. Писать сорок атомов руками незачем:
const status = Atom.family((code: string) =>
runtime.atom(Effect.flatMap(TowerStatus, (book) => book.open(code))),
);
console.log(status('T-2') === status('T-2')); // true
Семья атомов Atom.family запоминает атом по аргументу. Плашка T-2 в шапке витрины и плашка T-2 в списке читают один и тот же атом, а значит, книгу про T-2 спросят один раз. Та же мысль, что пачки и кэш в уроке 101, только со стороны витрины: двум читателям одного значения не нужны два вопроса.
Под капотом
Где тут дыра
let selected = 'T-1';
const label = `${selected}: спрашиваю книгу`;
const choose = (code: string) => {
selected = code;
};
Пока башню не меняли, всё верно: на стекле T-1: спрашиваю книгу. Горожанин выбирает T-3, choose('T-3') переписывает переменную, а label остаётся прежней строкой: её посчитали один раз, при запуске, и она не знает, что selected поменялся. Это та же меловая табличка, только в коде. Ринцвальд дописывает пересчёт в choose, потом второй, для плашки в шапке, потом третий, для списка. Каждая новая плашка это ещё одно место, где надо не забыть. Производный атом убирает список таких мест: зависимость записана в get, и пересчёт находит её сам.
Нестабильно. Модуль
effect/reactivityв 4.1 помечен@stability unstableцеликом:Atom,AtomRegistry,AsyncResultмогут поменять имена и опции в минорной версии. Урок написан наeffect@4.1.0.
Разминка
Собери витрину на атомах: статус башни семьёй атомов поверх рантайма из слоя, выбранную башню и подпись, которая честно говорит, что книгу ещё спрашивают.
Практика
Глубже в архиве
Как то же самое устроено в других библиотеках состояния, разобрано в уроке про Reatom и в обзоре состояния и форм раздела про фронтенд.
Дальше
К вечеру Чонси держит в лавке маленькую доску, на которой подпись меняется сама. Мела больше нет.
Витрина с улицы ждёт в следующем уроке.