В прошлом уроке витрина стала графом атомов: выбранная башня, статус каждой башни семьёй, подпись поверх них. Реестр знает, что от чего зависит, и зовёт подписчиков, когда значение поменялось. Страница на React перерисовывается, когда меняется её состояние. Осталось связать одно с другим: подписать компонент на атом так, чтобы React перерисовывал его по уведомлению реестра.
Это делает пакет @effect/atom-react. Он маленький: обёртка с реестром и хуки; витрине из них хватит трёх: useAtomValue, useAtom и useAtomSet.
Компонент читает атом
import { RegistryProvider, useAtom, useAtomValue } from '@effect/atom-react';
// selected и label это атомы витрины из прошлого урока.
export function Board({ towers }: { readonly towers: ReadonlyArray<string> }) {
const [current, select] = useAtom(selected);
const text = useAtomValue(label);
return (
<section aria-label="Витрина башен">
{towers.map((code) => (
<button key={code} type="button" aria-pressed={code === current} onClick={() => select(code)}>
{code}
</button>
))}
<p role="status">{text}</p>
</section>
);
}
export const App = () => (
<RegistryProvider>
<Board towers={['T-1', 'T-2', 'T-3']} />
</RegistryProvider>
);
Разберём по строкам.
RegistryProviderкладёт реестр в контекст React. Все компоненты под ним читают атомы из одного реестра. Это тот же реестр, чтоAtomRegistry.make()в прошлом уроке, только его создаёт эта обёртка.useAtomValue(label)читает атом и подписывает компонент на него. Поменялась подпись, и React перерисуетBoard. Читать атом значит держать его: покаBoardна странице, атом жив, и книгу про выбранную башню спросят.useAtom(selected)отдаёт пару: значение и функцию записи, какuseState. Только состояние живёт не в компоненте, а в атоме, и его видят все, кто читаетselected.aria-pressedговорит читалке экрана, какая кнопка нажата, аrole="status"просит её прочитать строку, когда та поменяется. Витрина для всех горожан, а не только для зрячих.
В компоненте нет ни одного запроса к книге. Он не знает, откуда берётся подпись и сколько её считать. Он читает атом. Всё, что было в прошлом уроке, runtime, family, AsyncResult, осталось в атомах.
Почему не useEffect с fetch
Миддл, который пишет на React давно, спросит: а зачем атомы, если есть useState и useEffect? Ответ виден, когда плашек становится две. Плашка T-2 в шапке и плашка T-2 в списке. С useEffect у каждой своё состояние и свой запрос: два вопроса к книге, два ответа, которые приходят в разное время. Полсекунды шапка говорит «открыта», а список «спрашиваю книгу». Горожанин видит две правды на одной странице.
С атомами обе плашки читают status('T-2'), один атом из семьи. Вопрос один, ответ один, и обе плашки меняются в один и тот же момент. Правило одно: компонент показывает состояние, а состояние живёт вне компонента.
Форма на Schema
Чонси хочет ещё и подписку: горожанин оставляет имя и башню, и ему скажут, когда башня откроется. Данные из формы это чужие данные, такие же, как кадр с провода в уроке 76. Их разбирают схемой, а не проверяют руками:
import { useAtomSet, useAtomValue } from '@effect/atom-react';
import { Effect, Schema } from 'effect';
import { AsyncResult, Atom } from 'effect/reactivity';
const Subscription = Schema.Struct({
name: Schema.NonEmptyString,
tower: Schema.String.check(Schema.isPattern(/^T-\d+$/, { message: 'код башни вида T-число' })),
});
const subscribe = Atom.fn((input: unknown) =>
Schema.decodeUnknownEffect(Subscription)(input).pipe(Effect.map((form) => `${form.name} ждёт ${form.tower}`)),
);
export function SubscribeForm() {
const submit = useAtomSet(subscribe);
const result = useAtomValue(subscribe);
return (
<form
onSubmit={(event) => {
event.preventDefault();
submit(Object.fromEntries(new FormData(event.currentTarget)));
}}
>
<input name="name" />
<input name="tower" />
<button type="submit">Подписаться</button>
<p role="status">
{AsyncResult.match(result, {
onInitial: () => '',
onFailure: () => 'проверь имя и башню',
onSuccess: ({ value }) => value,
})}
</p>
</form>
);
}
Atom.fn делает атом-функцию: в неё пишут аргумент, она запускает эффект, а её значение это AsyncResult последнего запуска. useAtomSet даёт функцию записи, useAtomValue показывает, чем кончилось. Форма отдаёт в атом всё, что ввели, как unknown, и только схема решает, годится ли это. Пустое имя или T2 без дефиса станут Failure, а не подпиской, которая никогда не сработает.
Окошки Почтамта атомами
Подписке рано или поздно нужен Почтамт. Его окошки описаны договором PochtamtApi из урока 91, и из того же договора выходят атомы:
import { useAtomSet, useAtomValue } from '@effect/atom-react';
import { FetchHttpClient } from 'effect/http';
import { AsyncResult, AtomHttpApi } from 'effect/reactivity';
class Pochtamt extends AtomHttpApi.Service<Pochtamt>()('vitrina/Pochtamt', {
api: PochtamtApi,
httpClient: FetchHttpClient.layer,
baseUrl: 'https://pochtamt.ankmor',
}) {}
const lettersFor = (to: string) =>
Pochtamt.query('letters', 'forAddress', { query: { to }, reactivityKeys: ['letters'] });
const send = Pochtamt.mutation('letters', 'send');
export function Letters({ to }: { readonly to: string }) {
const letters = useAtomValue(lettersFor(to));
const post = useAtomSet(send);
return (
<div>
<p>{AsyncResult.isSuccess(letters) ? `писем: ${letters.value.length}` : 'спрашиваю Почтамт'}</p>
<button
type="button"
onClick={() => post({ payload: { from: 'Чонси', to, text: 'витрина работает' }, reactivityKeys: ['letters'] })}
>
Написать
</button>
</div>
);
}
AtomHttpApi.Service это производный клиент из урока 92, только его методы отдают атомы. query делает атом чтения: значение это AsyncResult ответа окошка, типы запроса и ответа взяты из договора. mutation делает атом-функцию записи. Ключи reactivityKeys связывают одно с другим: после того как письмо отправлено с ключом letters, все запросы с этим ключом спросят окошко заново. Список писем на витрине обновится сам, без кода, который помнит, что после отправки надо перечитать.
Под капотом
Где тут дыра
function Badge({ code }: { readonly code: string }) {
const [text, setText] = useState('спрашиваю книгу');
useEffect(() => {
fetch(`/towers/${code}`)
.then((response) => response.json())
.then((tower: { open: boolean }) => setText(tower.open ? 'открыта' : 'закрыта'));
}, [code]);
return <p>{code}: {text}</p>;
}
С одной плашкой всё хорошо. Ринцвальд ставит вторую плашку той же башни, в шапку: два запроса вместо одного, и ответы приходят в разном порядке. Потом горожанин быстро жмёт T-2, T-3, T-2: три запроса, и ответ на T-3 может прийти последним. Плашка T-2 показывает «закрыта», потому что setText из старого запроса перезаписал свежий ответ.
Отмены нет: ушедший запрос нельзя остановить, только забыть. Ошибка книги тоже не видна: then без catch, и плашка навсегда «спрашиваю книгу». У атома все три вещи есть: один атом на башню, прерывание при отписке и Failure в AsyncResult.
Нестабильно. Модуль
effect/reactivityи пакет@effect/atom-reactв 4.1 помечены@stability unstable:AtomHttpApi, хуки и опции могут поменяться в минорной версии. Урок написан наeffect@4.1.0,@effect/atom-react@4.1.0и React 19.
Разминка
Модуль атомов из прошлой разминки дан готовым. Нарисуй витрину компонентом: кнопку на каждую башню и строку статуса выбранной. Тесты рендерят компонент в настоящее окно на happy-dom и жмут кнопки. Проверка типов у этой разминки выключена: декларации React опираются на типы браузера lib.dom, а их в браузерную проверку не кладут: это ещё 2,3 МБ, которые пришлось бы качать каждому, кто открыл урок.
Практика
Глубже в архиве
Хуки и подписки в React разобраны в уроке про хуки и рефы, тесты компонентов в настоящем DOM в уроке про тестирование React раздела про фронтенд.
Дальше
Вечером витрина Чонси уже висит на странице, и в лавке стало тише: люди смотрят из дома. Ринцвальд листает её с телефона, хмурится и жмёт на кнопки дольше, чем нужно.
Витрина выходит на улицу, в настоящий браузер горожанина, в следующем уроке.