Сердце любого современного сайта или браузерного приложения (что SPA, что PWA, что любые другие три буквы) — это его State, или состояние.


Мы можем сколько угодно спорить о том, что лучше — React, Vue, Svelte, Angular, можем продолжать пользоваться jQuery, но в действительности это не так важно. Это та часть нашего приложения, которое мы видим — его “мышцы“ и “кожа”. Но то, как вы думаете — какими терминами оперируете, какие механики используете для даже визуализации в голове того, как в вашем приложении “текут” данные — все это идет из его скелета. Из state manager-а.


Помните, пару лет назад у нас была усталость от JavaScript-а? Сейчас я вижу у огромного количества людей усталость от state manger-ов. Redux? Да, да and да. RxJS? Тоже. MobX? Если он такой простой — блин, почему у него есть в документации страница западни.html?


Ответ “почему многим так тяжело” есть, но сначала надо точно сформулировать проблему.


Выбирая state manger — мы выбираем образ мышления. Вариантов сейчас много, но самые популярные подходы бьются на 3 группы:


  • Flux/Redux-подобные: глобальное хранилище с action-ами и reducer-ами. Их довольно много, но я бы отметил сам Redux, Effector, Storeon, Unstated, и Reatom. Это не “лучшие из лучших”, скорее “самые разнообразные”. Все решения из списка несут в себе что-то уникальное и необычное, и из них можно выхватить разные интересные идеи.


    Этот подход в первую очередь императивный (Тюринг-полный) и глобальный.


  • Observables и пайпы. Самые популярые на сегодняшний день решения в этой группе — это RxJS и MobX. Из менее известных — Kefir, Bacon, CycleJS. Svelte, кстати, тоже попадает в этот список. Они все очень разные с точки зрения developer experience, но с фундаментальной точки зрения отличаются только в одном: MobX, Svelte и другие могут быть описаны с точки зрения топологии обычным графом связей “что триггерит что”, а вот RxJS — нет, его граф связей многомерен и в нем могут возникать “странные петли”, передавая обзерваблы в обзерваблах. Это делает его с одной стороны более мощным, с другой — более сложным. Похожая история была с тайпскриптом. Узнали, что его система типов Тюринг-полна. Единственное, что из этого следовало — так это то, что он может зависнуть на шаге проверки, и они добавили ограничение по времени работы.


    Это может прозвучать довольно странно, но в целом этот класс решений стремится быть локальным, или ad-hoc и декларативным — но не отрезая себе возможность пользоваться произвольной логикой.


    Дело в том, что на каком-то уровне, гхм, осознания многие разработчики, склонные к парадигмам функционального и реактивного функционального программирования начинают описывать преобразования данных, и при помощи кода “рисовать” пайплайны. Они стараются избегать делать произвольные функции, используя вместо этого библиотеки вроде Lodash, Ramda, или io-ts. В какой-то момент такой код начинает быть похожим на LISP поверх JS, но в целом это довольно удобно — большая часть логики уезжает в не-тюринг-полный “язык”, который легче поддерживать, чем обычный код.


  • GraphQL и ему решения. Apollo и Relay — это два самых известных примера, но в целом их очень мого. Отдельно стоит упомянуть Falcor — альтернативу GraphQL от Netflix, GunDB и PouchDB. Более того, многие из этих решений интегрируются с Redux, MobX, RxJS и всеми остальными решениями. Но наличие интеграций не умаляет “отдельности” GraphQL. Важно то, что это декларативный глобальный подход к хранению состояния. И он как раз на 100% декларативный (если не считать редких расширений)



У нас есть два основных аспекта, определяющих наше мышление. Состояние для нас — это либо код, либо структура данных; и хранится оно либо на уровне отдельных сущностей, либо где-то преимущественно глобально. И это разбиение вызывает множество вопросов.


Императивный подход Декларативный подход
Глобальное состояние Flux GraphQL
Локальное состояние Observables ?????

Стоит сделать оговорку: термины “локальный” и “глобальный” могут быть не на 100% корректны: можно запихнуть redux-хранилище в контекст или компонент, или вообще его загружать по запросу, а RxJS — положить в глобальную переменную. Но в JS в целом можно сделать почти что угодно, поэтому логично будет разделить по тому, как рекомендуют и в большинстве своем пользуются этими решениями.


Эмпирическое правило примерно такое: если что-то в обязательном порядке обладает ID или списком ID, позволяющим получить эту сущность — это глобальный подход. Если привязку к данных можно передать по указателю (переменной), и это рекомендованный способ работы с ней — это локальный подход.


Потерянное звено


Это странно. У нас нет локально-декларативного менеджера состояния. Возможно, есть пара эзотерических решений, которые позволяют так делать, но в "state of js” не упоминается ни одного решения.


И вот в чем дело.


Мы пытаемся смешать две разных вещи вместе.

У нас есть состояние, существующее в рамках нашего рантайма, в браузере. Нас не волнует, локальное оно или глобальное — тут каждый выбирает то, что ему удобней. Но в целом опыт подсказывает, что лучше им управлять в императивной манере.

И у нас есть наше знание о происходящем в глобальной системе — браузере, бэкэнде, у других пользователей и внешних сервисов. Мы можем влиять на это знание, но мы знаем, что именно в нем может измениться после любого из наших действий — мутации GraphQL как раз про это. Это значит, что мы можем описать, какие именно данные мы хотим знать о состоянии нашей глобальной системы.

Когда мы смешиваем состояние и знание, мы делаем что-то фундаментально неправильное.

Модели


С самого появления SPA мы считали, что оперируем исключительно моделями: моделью чекбокса, поста в блоге, SQL-записи или графа связей; и мы постоянно спотыкались о проблемы стыковки локального и удаленного состояний.


Этот подход мы перенесли из классических приложений вроде решений на Rails, и знания о том, как вообще делаются приложения.


Ирония заключается в том, что если посмотреть, как работают большие и сложные серверные приложения (и многие мобильные) — можно увидеть это разделение между ответами низлежащих API и баз данных, и локального состояния.


Обычно используется следующая комбинация:


  • Слой знания: автоматическое кэширование ответов и их инвалидация. Нюанс в том, что обычно он спрятан от глаз рядового разработчика, и не каждый вообще задумывается, что это отдельное полноценное хранилище данных.
  • Слой состояния: порой это конечный автомат или statechart. Порой — отдельные классы с состоянием. Иногда — это те же самые observables (RxJava, RxRuby, RxSwift, RxBrainfuck — идея понятна) с логикой как раз внутри топологии. Иногда — какое-то самописное решение, возможно, даже смешанное с духе спагетти-кода с остальными частями.

Решение


Пора разделить состояние и знание. Мы уже научились разделять представление и остальное приложение, и мы знаем, как это делать правильно. Чтобы перейти на следующий уровень — нужно сделать еще одно разделение. Нужно принять новую ментальную модель, в которой мы будем понимать, что часть сущностей, которыми мы оперируем, наше состояние — в нашей собственности. Мы можем делать с ними что хотим, и они могут быть любыми, включающими в себя функции и указатели на какие-то глобальные вещи. А часть — знания, предоставленные нам.


Знание о других частях системы — это какие-то данные, которые нам предоставлены. Оно представляет из себя описание части состояние какой-то конкретной удаленной системы, и именно это делает его глобальным в рамках нашего приложения. В идеале — каждая единица нашего знания должна обладать метаданными, которые описывают, откуда и что мы взяли, и по каким правилам это должно быть инвалидировано.


И они не принадлежат нам. Если мы начнем его менять, они станут, гхм, corrupted. Они ограничены: мы не должны их менять, и они обязаны быть представимыми в качестве DTO. Знание не может включать в себя обычные функции. Если нужно в них держать логику — можно пользоваться маленьким аналогом LISP-а. Не волнуйтесь: согласно десятому правилу Гринспена, он у вас будет в любом случае.


Состояние же — про наше локальное приложение. Да, если у вас микросервисы на клиенте — вы можете рассматривать данные из отдельных микросервисов как знание, но это исключительный случай, который стоит проработать отдельно, но в целом — если вам нужна какая-то переменная для текущих задач, это именно состояние. Мы можем хранить в нем функции, ссылки на глобальные объекты, что угодно. Важное правило: если нужно что-то сделать с имеющимся у нас знанием — мы должны ссылаться на него, а не изменять. Скопировать, держать в состоянии патчи для него, или воспользоваться CRDT-типами. Это чудовищно облегчает нашу ментальную модель — даже если приходится писать немного больше кода вначале.


И еще раз:


Ваше клиентское приложение работает с двумя мирами.


Первый из них для нас — лишь проекция, платоновская пещера, где реальный мир — это сервера, базы данных, права доступа и запросы. Мы же видим лишь отдельные части этого мира, которые нам разрешено увидеть. Мы не можем и не должны скачивать всю базу данных на клиент, поэтому у нас есть запросы к ней, ответы на которые не должны быть изменены. Мы можем пользоваться как императивным подходом await getBlogPost(id), так и декларативным: @gql("blogPost(id){...}") class extends Component, но если вы пользуетесь декларативным — ваш код становится проще и более легко поддержваемым, поэтому стоит придерживаться именно его.


С данными из мира знаний мы обязаны обращаться осторожно. Они должны быть иммутабельны. Берите ImmutableJS, Object.freeze, readonly Тайпскрипта или Record & Tuple stage 1 proposal. Или просто введите правила. Такое знание можно хранить даже в сервис-воркере или shared worker.


Второй мир, мир состояния — это наше королевство, где мы можем быть великодушным пожизненным диктатором. Я настоятельно советую пользоваться XState для управления состоянием чего угодно более сложного, чем чекбокс (да и для него может быть нужно), но это ваш выбор. Берите, что хотите. Просто держите его подальше от знания.


Любое взаимодействие между двумя мирами должно быть явным, прямо-таки выделяющимся в коде, чтобы не пройти во время code review случайно, а главное — происходить в userland-е, а не в библиотеках.


Вы не должны делать выбор в пользу конкретных решений, но для своего нового проекта я взял бы GraphQL + Apollo для хранения знаний, а для управления состоянием — Xstate + RxJS, благо, они дружат друг с другом.


Перестаньте беспокоиться о том, как запихнуть все в одно решение. Вам это не нужно.