Раздел 46 · Effect: Тихая Академия

Окно в лавку: атомы в React

middle~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке витрина стала графом атомов: выбранная башня, статус каждой башни семьёй, подпись поверх них. Реестр знает, что от чего зависит, и зовёт подписчиков, когда значение поменялось. Страница на 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 раздела про фронтенд.

Дальше

Вечером витрина Чонси уже висит на странице, и в лавке стало тише: люди смотрят из дома. Ринцвальд листает её с телефона, хмурится и жмёт на кнопки дольше, чем нужно.

Витрина выходит на улицу, в настоящий браузер горожанина, в следующем уроке.