Привет, меня зовут Артём Арутюнян и я автор менеджера состояния Reatom. Этим постом открывается серия обучающих материалов на русском языке, документация на английском доступна на официальном сайте.
А оно вам надо? Думаю, да, потому что Reatom — это универсальное решение, которое позволяет легко пошарить глобальное состояние за микроскопическую (2.5KB) цену, эффективно строить самодостаточные и переиспользуемые логические модули гигантских приложений или просто сделать ваш сетевой кеш реактивным с помощью дополнительного пакета @reatom/async.
В этой статье мы кратко пройдёмся по мотивации и истории, а потом разберём основные фичи и примеры их использования вместе с биндингами к React.js. Похожий разбор есть в виде скринкаста.
▍ Мотивация
Я ленивый идеалист с каким-то пунктиком на перформанс (у меня часто были маломощные ноутбуки и смартфоны) и я всё время попадал на проекты с сотнями тысяч или миллионом строк кода, хотя иногда касался и мелких стартапов. И всегда мне хотелось использовать одно решение, не переключаясь между контекстами, которое будет и маленькое, и эффективное, и позволяло бы использовать энтерпрайзнутые паттерны, которые нужны, когда цена любого рефакторинга может быть огромной.
Мне нравилось, как реактивность решает проблемы связанности кода, а иммутабелность упрощает дебаг — это и стало главными столпами разрабатываемой библиотеки.
Сложно сделать хорошо всё и сразу, поэтому эволюция Reatom заняла годы.
▍ История
Первый релиз был осенью 2019-го, хотя ему предшествовали почти два года исследований. Началось всё в феврале 2018-го, тогда меня передёрнуло от function-tree, и я решил сделать с подобным апи убийцу редакса (тогда это было популярным занятием). Далее история долгая: погружение в дзен вывода типов TypeScript, десятки прототипов, постоянные попытки выжать лучшее из современных технологий. Исследование алгоритмов обхода графов для решения проблемы глитчей. Рост комьюнити и попытки писать понятную документацию. Обслуживание инфраструктуры монорепы. Погружение в теорию баз данных, которые я администрировал ещё в 2014-м, но не задавался вопросами подкапотной архитектуры. Переосмысление архитектуры веб-приложений и состояния как явления. Один из артефактов всего этого — недавняя статья «Что такое состояние», в которой изложены ключевые принципы архитектуры менеджера состояния.
Но главное — первая LTS и вторая версия реатома пытались быть совместимы с редаксом, и сколько я ни старался, нормально это сделать не выходило, он просто фундаментально сломан:
- O(n) сложность, где n — количество подписчиков;
- единая очередь для подписчиков и вычисляемых значений, из-за чего в селекторах нет атомарности;
- невозможность батчинга (диспатча нескольких экшенов).
Бойлерплейт для меня всегда был меньшей проблемой, но вы просто посмотрите на эту разницу между тулкитом(!) и реатомом. По ссылке используется пакет @reatom/framework, который включает в себя базовый набор самых часто используемых пакетов и просто реэкспортит из них всё для удобства установки и импорта. В последние пару лет требования к развитой экосистеме всё важнее. В 2020-м было нормально иметь маленькую библиотеку и дать на откуп пользователей писать и публиковать в NPM свои хелперы. Но сейчас индустрия уже повзрослела и предъявляет взвешанные требования к экосистеме, её слаженности и поддержке. Все пакеты Reatom хранятся в монорепе, что позволяет тестировать любое изменение со всеми зависимостями и синхронизировать релизный цикл, сделав его предсказуемым.
Это что касается технического аспекта поддержки, в общем же политика выглядит так: нечётные релизы считаются LTS и поддерживаются несколько лет. Первая версия поддерживалась три года, сейчас можно подменить импорты и использовать код на ней дальше с новыми фичами и дальнейшей поддержкой. Текущая третья версия (@reatom/core@3.x.x) будет актуальна ещё несколько лет. Раз в год возможны небольшие ломающие изменения от рефакторинга типов.
▍ Базовые сущности
Cперва взглянем на код базового примера из этой песочницы:
import { action, atom } from '@reatom/core'
import { useAction, useAtom } from '@reatom/npm-react'
const inputAtom = atom('')
const greetingAtom = atom((ctx) => `Hello, ${ctx.spy(inputAtom)}!`)
const onChange = action((ctx, event) =>
inputAtom(ctx, event.currentTarget.value),
)
export const Greeting = () => {
const [input] = useAtom(inputAtom)
const [greeting] = useAtom(greetingAtom)
const handleChange = useAction(onChange)
return (
<>
<input value={input} onChange={handleChange} />
{greeting}
</>
)
}
Это самый базовый пример трёх ключевых сущностей: контекст, атом, экшен. На их основе можно реализовать большинство популярных паттернов из ФП или ООП и расширять по необходимости дополнительными фичами, больше десятка существующих пакетов этому пример. Но давайте разберём каждую строчку детальней.
Конечно, больше всего вопросов вызывает ctx
. Это некий глобальный DI-контейнер на стероидах, заточенный под стейт-менеджемент. Он позволяет читать актуальный стейт атома ctx.get(anAtom)
, подписываться на него ctx.subscribe(anAtom, newState => sideEffect(newState))
и планировать сайд-эффекты во время транзакции, но об этом попозже. Главное, что нужно запомнить — ctx
прокидывается первым аргументом в большинстве колбэков реатома и каждый раз приходит новый (под капотом содержит весь стек предыдущих контекстов вплоть до глобального).
inputAtom
— базовый атом и кирпичик, с которого всё начинается. Это переменная для реактивного доступа и обновления данных. Хорошей практикой является разделять все ваши состояния на множество атомов с примитивными значениями. Для пользователей редакса это может быть чуждо, но в процессе использования всё больше будет ощущаться профит от такого подхода.
Базовый атом может быть вызван как функция с новым значением или редьюсером этого значения: countAtom(ctx, 1)
и countAtom(ctx, state => state + 1)
. Такой вызов функции возвращает новое значение атома. В типах такой атом называется AtomMut
(mutable atom).
Благодаря такому апи код быстро и удобно парсить глазами:
ctx.get
иctx.spy
получают значение атома, аdoSome(ctx)
иsomeAtom(ctx, value)
мутируют.
greetingAtom
— вычисляемый атом, который вызывает переданную функцию при первой подписке и с помощью метода spy
в контексте подписывается на переданный атом и получает его значение. Это map
и combine
в одном флаконе, только гибче и удобнее. В типах такой атом называется просто Atom
. У вычисляемых значений реатома есть две киллер-фичи, каждая из которых отдельно встречается в ФП (редакс) или ООП (мобыкс) мире, но я ещё не встречал их вместе.
Первое — если вычисление в редьюсере (да, вторым аргументом приходит предыдущий стейт) упадёт с ошибкой, все предыдущие изменения в текущей транзакции откатятся и будет соблюдена атомарность (рассказывал об этом здесь). Это важный аспект, позволяющий не допустить неконсистентные данные, и он важен для крупных приложений. Базовое поведение React практически такое же, даже жёстче, всё приложение размонтируется (в случае отсутствия componentDidCatch
).
Второе — вы можете использовать spy
в любом порядке (привет, правила хуков реакта). Вы можете применять его в условии и подписываться только на нужные атомы, когда это действительно актуально, что оптимизирует автоматически ваши потоки данных и помогает избавится от лишних вычислений.
const listAtom = atom([])
const listAggregatedAtom = atom(ctx => aggregate(ctx.spy(listAtom)))
const listViewAtom = atom((ctx) => {
if (ctx.spy(isAdminAtom)) {
return ctx.spy(listAggregatedAtom)
} else {
return ctx.spy(listAtom)
}
})
Подобный код на реселекте или ФРП-библиотеке, скорее всего, получал бы вместе isAdmin
, list
и listAggregated
и способствовал избыточным вычислениям (для isAdmin === false
). Конечно, в теории можно описать селекторы, которые будут делать примерно то же самое, но на практике так не заморачиваются и получают очередную каплю в замедление приложения. В реатоме такие условные подписки — базовый принцип.
Удобно использовать в вычисляемом атоме и простой ctx.get
для чтения какого-то значения — это не создаёт подписку, но гарантированно отдаёт самый актуальный стейт переданного атома.
На самом деле атомы не хранят значения, а являются лишь объектом с метаданными и ключом WeakMap-контекста, где и хранятся все стейты и связи между атомами. Это позволяет прозрачно и безопасно инстанцировать цепочки вычислений и упрощает SSR и тестирование.
onChange
— экшен, хелпер для батчинга изменений. Если у вас есть несколько атомов для последовательного обновления, каждое изменение будет тригерить их зависимые вычисления и подписчиков. Что бы забатчить вычисления, можно использовать колбэк в ctx.get(() => {...})
или просто создать выделенный экшен и произвести все апдейты в нём. Экшены удобны тем что им можно, как и атомам, давать имена (второй аргумент), что в дальнейшем упрощает дебаг @reatom/lgger.
Про TypeScript, реатом разрабатывается с большим фокусом на автоматическом выводе типов и всегда старается понять переданные данные. Если вам необходимо затипизировать параметры экшена, просто укажите их тип у них же: action((ctx, event React.ChangeEvent) => ...). Больше рекомендаций по описанию типов ищите в документации.
Под капотом экшен — это атом со временным стейтом, хранящий params вызова и возвращённый payload. С ним можно делать всё то же, что и с атомом: подписываться через
ctx.subscribe
для сайд-эффектов иctx.spy
в вычисляемом атоме. Например, можно в вычисляемом атоме получить данные другого атома только при срабатывании какого-то экшена — это редкий, но очень удобный способ оптимизации. Больше примеров и возможных паттернов разберём в следующих статьях.
Стоит упомянуть о ctx.schedule
, который позволяет планировать сайд-эффекты, как useEffect
в реакте. Его можно вызывать где угодно, но чаще всего это пригождается в экшенах.
const onSubmit = action((ctx) => {
const input = ctx.get(inputAtom)
inputAtom(ctx, '')
ctx.schedule(() => api.submit({ input }))
})
Переданный в ctx.schedule
колбэк будет вызван после всех чистых вычислений, но до вызова подписчиков — это удобно, т. к. иногда эффекты просто сохраняют что-то в localStorage или делают другие не чистые, но синхронные операции и вызывают ещё апдейты. Подробности есть в документации, в общем же реатом старается всегда максимально отложить вызов подписчиков, чтобы избежать лишних ререндеров и предоставить самый последний и актуальный стейт. У редакса с этим ситуация радикально хуже.
▍ @reatom/npm-react
Все пакеты-адаптеры имеют префикс платформы (
npm
,web
,node
), подробнее об этом можно почитать в документации.
Думаю, использование useAtom
и useAction
понятно и практически не нуждается в комментариях :) Хотя несколько вещей всё же нужно учесть.
В документации к npm-react описаны обязательные инструкции по подключению реатома в провайдер реакта и настройке батчинга для старой (<18) версии реакта.
import { createCtx } from '@reatom/core'
import { reatomContext } from '@reatom/npm-react'
const ctx = createCtx()
export const App = () => (
<reatomContext.Provider value={ctx}>
<Main />
</reatomContext.Provider>
)
Почему useAtom
возвращает кортеж, как useState
? Потому что его можно использовать как useState
! Вторым значением приходит колбэк обновления, который принимает новое значение или редьюсер.
export const Greeting = () => {
const [input, setInput] = useAtom(inputAtom)
const [greeting] = useAtom(greetingAtom)
return (
<>
<input value={input} onChange={e => setInput(e.currentTarget.value)} />
{greeting}
</>
)
}
Конечно, это будет работать только для AtomMut
— невычисляемого атома с примитивным начальным значением.
Но не будем на этом останавливаться. Вы можете не создавать отдельный атом и использовать его в разных местах через импорты, а можете использовать примитивное значение в useAtom
, и атом под капотом будет создан автоматически, а ссылку на него можно получить в третьем элементе кортежа. Также вы можете передать и вычисляемый редьюсер в useAtom
, как и в обычный atom
, и описать какой-то селектор прямо в компоненте.
export const Greeting = () => {
const [input, setInput, inputAtom] = useAtom('')
const [greeting] = useAtom((ctx) => `Hello, ${ctx.spy(inputAtom)}!`, [inputAtom])
return (
<>
<input value={input} onChange={e => setInput(e.currentTarget.value)} />
{greeting}
</>
)
}
Это может быть удобно для некоторых оптимизаций, смысл которых в передаче получившегося атома по ссылке в пропсы нижележащим компонентам. Подробнее этот паттерн мы разберём в следующих статьях.
▍ Заключение
Это всё, чем хотелось поделиться в первой статье, но реатом таит в себе ещё множество фич и архитектурных практик, которые сильно помогают в написании более чистого и тестируемого кода. Но обо всём поэтапно, ждите серию постов.
Можно лишь отметить, что любителям ФРП стоит обратить внимание на пакет @reatom/lens, а сторонникам более классической архитектуры — взглянуть на пакет @reatom/hooks, который позволяет писать более изолированный код, приближенный к акторам.
Ах да, и про реактивный кеш. Пакет @reatom/async в связке с базовыми фичами реатома даёт большую часть фич react-query, а какие-то даже превосходит, всего за 3.4KB (gzip).
Смотрите больше примеров на соответствующей странице документации, добавляйтесь в Телеграм-канал и чат. И, конечно, оставляйте ваши комментарии и вопросы ниже.
Автор: Артём Арутюнян