Раздел 7 · Браузер и Frontend

Reatom: атомы, граф и точечная реактивность

middle-senior~35 мин

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

Reatom: атомы, граф и точечная реактивность

Echo на React и Redux Toolkit функционально полон. Но это лишь одна из трёх реализаций, которые мы соберём, чтобы пощупать разные модели реактивности на одной фичу-сетке. Сейчас форкаем второй workspace, echo-react-reatom: тот же UI, тот же backend, но store не на Redux, а на атомах. Цель не выучить ещё одну библиотеку, а увидеть, чем атомарный реактивный подход отличается от журнала-диспетчера Redux. Заметь: код v1000, в нём нет ctx, который ты мог встречать в старых статьях про Reatom.

1. Паутина вместо журнала

В Redux всё состояние течёт через один журнал: каждое изменение это action, прошедший через reducer, и любой подписчик при каждом dispatch проверяет, не поменялся ли его срез. Reatom устроен иначе. Представь паутину: каждый атом это узел, нити между ними это зависимости. Дёрнул один узел, и волна идёт только по связанным нитям, остальная паутина не шелохнётся. Нет глобального reducer, нет обхода всех подписчиков. Это и есть точечная реактивность.

Три кирпича Reatom: atom это узел состояния, computed это узел, выведенный из других, action это функция-событие, которая что-то меняет. Ни диспетчера, ни reducer-дерева.

Что взять с собой

  • В Reatom состояние это граф мелких атомов, а не одно дерево с одним reducer.
  • Изменение атома распространяется только по зависимым нитям (точечно).
  • Кирпичи: atom (узел), computed (производный узел), action (событие).

2. atom, computed, action

Атом создаётся одной функцией. Читают его вызовом, меняют через set:

import { action, atom, computed } from '@reatom/core';

const counterAtom = atom(0, 'counterAtom');

counterAtom(); // прочитать: 0
counterAtom.set(5); // записать значение
counterAtom.set((count) => count + 1); // обновить от предыдущего

Второй аргумент 'counterAtom' это имя для devtools и логов, его проставляют всегда. Дальше производное значение. computed читает другие атомы, просто вызывая их, и Reatom сам запоминает зависимость:

const doubledAtom = computed(() => counterAtom() * 2, 'doubledAtom');

Вот ключевой момент: внутри computed мы вызвали counterAtom(), и этим объявили doubledAtom зависит от counterAtom. Никакого списка зависимостей руками, как в useMemo, и никакого ctx.spy, как в старом Reatom. Просто вызов. Когда counterAtom изменится, doubledAtom пересчитается, а если нет, отдаст запомненный результат.

Изменения заворачивают в action, событие с именем:

const increment = action(() => counterAtom.set((count) => count + 1), 'increment');

increment(); // вызвать, как обычную функцию

Сравни с Redux. Там ты диспатчишь объект action, он идёт через reducer, reducer возвращает новый срез одного большого дерева. Здесь ты вызываешь функцию, которая меняет конкретный узел. Меньше бойлерплейта, но и нет единого журнала, по которому Redux так удобно отлаживать.

Что взять с собой

  • atom(initial, name): читать atom(), писать atom.set(value) или .set(fn).
  • computed(() => ...) объявляет зависимость самим вызовом атома внутри, без списков.
  • action(() => ..., name) это функция-событие, вызывается напрямую.
  • Имя вторым аргументом проставляй всегда: оно нужно devtools и логам.

3. Граф зависимостей и точечный пересчёт

Когда один computed читает другой, который читает третий, складывается граф. Изменение источника катится по нему волной, но только по тем нитям, что реально ведут к источнику. Это видно лучше, чем читается. Нажимай action, который меняет один source-атом, и смотри, какие производные пересчитались, а какие остались холодными.

Главное наблюдение: меняешь searchQueryAtom, и пересчитывается только filteredAtom, а currentMessagesAtom и unreadCountAtom даже не просыпаются, они от поиска не зависят. В Redux так не выйдет: там каждый useSelector выполнил бы свой селектор заново на любой dispatch и лишь потом сравнил результат по ссылке, чтобы решить, перерисовываться ли. Reatom знает граф заранее и будит ровно нужные узлы. Это и есть выигрыш точечной реактивности: чем больше у тебя зависимых вычислений, тем заметнее разница.

Что взять с собой

  • Связи computed образуют граф, по которому изменение идёт волной.
  • Пересчитываются только узлы на пути от источника, остальные остаются холодными.
  • В Redux каждый селектор гоняется заново и спасается мемоизацией, в Reatom лишнего пересчёта нет по построению.

4. Серверные данные: withAsyncData и withAsync

Сообщения это server state, и Reatom закрывает его не отдельной библиотекой кэша, как RTK Query, а расширением обычного computed. Запрос это вычисление, зависящее от параметров, значит это computed, только асинхронный:

import { atom, computed, wrap, withAsyncData } from '@reatom/core';

import { type Message } from '../data/types';

export const activeChannelAtom = atom('general', 'activeChannelAtom');

export const messagesQuery = computed(async () => {
  const channelId = activeChannelAtom(); // зависимость: при смене канала перезапрос
  const response = await wrap(fetch(`/api/channels/${channelId}/messages`));
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return (await wrap(response.json())) as Message[];
}, 'messagesQuery').extend(withAsyncData({ initState: [] }));

Разберём по частям. withAsyncData превращает асинхронный computed в полноценный кэш-запрос: messagesQuery.data() отдаёт последние сообщения, messagesQuery.ready() говорит, идёт ли загрузка, messagesQuery.error() отдаёт ошибку. А поскольку внутри мы вызвали activeChannelAtom(), запрос зависит от активного канала: сменили канал, запрос перезапустился сам, а предыдущий, ещё летящий, отменился. Та самая гонка запросов, которую в RTK Query гасят теги, здесь решена графом зависимостей.

Функция wrap оборачивает каждый await, чтобы Reatom видел асинхронную работу и мог её отменить. Мутации (отправка, удаление) не хранят результат, для них есть парное расширение withAsync поверх action:

import { action, wrap, withAsync } from '@reatom/core';

export const sendMessage = action(async (channelId: string, text: string) => {
  const response = await wrap(
    fetch(`/api/channels/${channelId}/messages`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ text }),
    }),
  );
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return (await wrap(response.json())) as Message;
}, 'sendMessage').extend(withAsync());

sendMessage.ready(); // идёт ли отправка
sendMessage.error(); // ошибка отправки

Персист тоже одно расширение. Авторизацию сохраним в localStorage, чтобы пережить перезагрузку, и она засинхронится между вкладками автоматически:

import { atom, withLocalStorage } from '@reatom/core';

export const authAtom = atom<User | null>(null, 'authAtom').extend(withLocalStorage('echo-auth'));

Что взять с собой

  • Серверный запрос в Reatom это асинхронный computed плюс withAsyncData.
  • .data(), .ready(), .error() отдают данные и статус, гонки гасит авто-отмена.
  • Запрос сам перезапускается при смене зависимостей (тут активного канала).
  • withAsync поверх action для мутаций, withLocalStorage для персиста.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.