О том, как я написал свой стейт-менеджер

—

от автора

Хочу посвятить эту статью погружению в разные возможности обновления данных в JS и React, раскрыв это на примере своей библиотеки «nexus-state». Поделюсь подробными примерами, мотивацией и расскажу, для чего вообще нужны стейт-менеджеры и как они работают. При написании примеров я постараюсь максимально просто объяснить суть и не буду затрагивать тему типизации.

Вступление

Идея названия пришла внезапно. Пройдя все серии одной из моих любимых игр,«Dark Souls», я наткнулся на ранний проект «Demon’s Souls» (по иронии, в него я так и не играл), а точнее, на описание «Nexus». В контексте игры это своего рода хаб, куда игрок может перенестись, прокачаться и отдохнуть.

Название понравилось. Оно латинского происхождения и означает «связь», «узел», «соединение» — и в этом была аналогия: разработчик, словно протагонист игры, проходит через трудности своего проекта и всегда имеет доступ к Nexus, откуда может получить то, что ему нужно. В контексте разработки это данные. Проще говоря, та самая связь — связь с данными.

В npm имя оказалось занято, так что библиотека стала называться «nexus-state». От себя добавлю, что мне хотелось, чтобы у тех, кто пользуется библиотекой, оставалось то же ощущение опоры на тернистом пути разработки, в виде той самой связи с данными через «nexus-state».

Пролог

Имея за плечами годы работы UI/UX дизайнером, небольшие знания JS и опыт вёрстки HTML с CSS, примерно в 2021 году я столкнулся с React. Это был логичный следующий шаг в моём профессиональном развитии.

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

Одно из них — работа с состоянием. Точнее, вопрос о том, как удобно обновлять одни и те же данные, находящиеся в разных компонентах. Именно на него в первую очередь и отвечают все так называемые глобальные стейт-менеджеры. Разберём пример. Допустим, у нас есть сайт игры, где надо показать имя героя и его класс сразу в нескольких компонентах:

function App() {  return (    <>      <Header />      <Sidebar />      <Profile />    </>  );}

Хук useState тут не поможет: он работает только локально, и хранить одни и те же данные отдельно в каждом компоненте мы не можем — они разъедутся.

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

Первое время спасают хуки мемоизации, но рано или поздно это приводит к лишним сложностям. Есть ещё useContext — о нём подробнее позже, но в начале пути это было не то, что хотелось использовать.

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

                   Store                     │        ┌────────────┼────────────┐        ↓            ↓            ↓      Header       Sidebar      Profile

Готовых решений для этого много. Но я только начинал разбираться во фронтенде, и меня не устраивало главное: при их использовании я не понимал, что происходит внутри. Всё работает — а как именно, неизвестно. Со стороны это выглядит магией.

И тут мне попадается статья про Flux, которая знакомит меня с понятиями «издатель/подписчик». Я влюбляюсь в эту архитектуру и начинаю её применять.

Чувство контроля

Flux убрал всю магию, сделав процесс работы с данными максимально прозрачным. Приведу простой пример стора, реализованного по архитектуре Flux, пока без Dispatcher — к нему мы вернёмся позже.

// myStore.js// создаём наши дефолтные данныеlet state = {  name: "Duncan",  role: "knight",};const listeners = new Set(); // создаём коллекцию слушателейexport const store = {  // метод получения данных  getState() {    return state;  },  // метод установки данных  setState(newState) {    state = {      ...state,      ...newState,    };    // проходимся по всем подписчикам, уведомляя об изменениях    listeners.forEach((listener) => listener());  },  // метод для подписки на обновления  subscribe(listener) {    listeners.add(listener);    return () => listeners.delete(listener); // возвращаем возможность отписки  },};

Таким образом мы получили методы для обновления и получения данных, используя это так:

import { store } from "./myStore";console.log("state -", store.getState()); // выводим данные в консоль// получаем state - { name: "Duncan", role: "knight" }

Сейчас Flux вообще не знает, что существует React. Store умеет хранить состояние и уведомлять подписчиков о его изменении, но сам по себе не может заставить React перерисовать компоненты.
Давайте напишем для этого небольшой React-хук.

// useStore.jsimport { useSyncExternalStore } from "react";import { store } from "./myStore";export function useStore() {  return useSyncExternalStore(store.subscribe, store.getState);}

Для примера я использовал встроенный React-хук useSyncExternalStore, который позволяет подписать компонент на внешний источник состояния — в нашем случае Flux Store. Когда Store сообщает об изменении, React получает актуальное состояние и при необходимости выполняет повторный рендер компонента.

Но useSyncExternalStore здесь не является частью Flux. Это всего лишь связующее звено между Flux-Store и React.

Чтобы лучше понять, как эта связь работает, давайте попробуем реализовать свой useStore самостоятельно, используя более простые React-хуки — useEffect и useState.

// useStore.jsimport { useEffect, useState } from "react";import { store } from "./myStore";export function useStore() {  const [state, setState] = useState(store.getState);  useEffect(() => {    return store.subscribe(() => {      setState(store.getState());    });  }, []);  return state;}

Здесь происходит довольно простая вещь: при создании компонента мы подписываемся на Store. Когда Store изменяется, его subscribe вызывает наш callback, а тот передаёт новое состояние в setState. Изменение локального состояния заставляет React выполнить повторный рендер, и хук возвращает уже актуальное состояние Store. Это и делает хук useSyncExternalStore.

Обратите внимание на useState(store.getState): функция передана без вызова — это ленивый инициализатор. React сам вызовет её один раз при первом рендере и положит в состояние результат, а не саму функцию.

При этом сам Flux по-прежнему ничего не знает о React. Он лишь предоставляет два механизма: получить состояние (getState) и подписаться на его изменения (subscribe). Всё, что происходит дальше — это уже задача React-интеграции.

Так мы уже сделали рабочее решение для менеджмента данных и при этом не теряем контроль на каждом шаге. Сейчас мы можем обновлять данные, но у текущей реализации есть проблема: store.setState сливает в состояние любые переданные ключи, и по коду невозможно понять, какие обновления вообще предусмотрены. И тут стоит дополнить текущую архитектуру концепцией Dispatcher, сузив обновления до известного словаря действий.

// dispatcher.jsconst handlers = new Set(); // обработчики действий// создаём dispatcherexport const dispatcher = {  register(handler) {    handlers.add(handler);    return () => handlers.delete(handler);  },  // notify передаём дальше, чтобы обработчик сам решал, когда уведомлять  dispatch(action, notify) {    handlers.forEach((handler) => handler(action, notify));  },};

Теперь добавляем dispatcher к нашему Flux:

// myStore.jsimport { dispatcher } from "./dispatcher";let state = {  name: "Duncan",  role: "knight",};const listeners = new Set();// регистрируем варианты использования здесь — потому что здесь живёт statedispatcher.register((action, notify) => {  switch (action.type) {    case "HERO_SET_NAME":      state = {        ...state,        name: action.payload,      };      break;    case "HERO_SET_ROLE":      state = {        ...state,        role: action.payload,      };      break;    // неизвестное действие ничего не меняет и никого не будит    default:      return;  }  notify(); // здесь вызывается та функция, что обновит подписчиков});export const store = {  getState() {    return state;  },  // метод отправки действия  dispatch(action) {    // передаём именно функцию: вызвать её должен обработчик, после изменения    dispatcher.dispatch(action, () => {      listeners.forEach((listener) => listener());    });  },  subscribe(listener) {    listeners.add(listener);    return () => listeners.delete(listener);  },};

Заметьте и то, что метод стора теперь называется dispatch, а не setState: он

принимает действие, а не кусок состояния, и старое имя только путало бы.

Теперь обновления описываются двумя параметрами: type говорит, что именно меняем, а payload несёт новые данные.

import { store } from "./myStore";store.dispatch({  type: "HERO_SET_NAME",  payload: "Artorias",});

Так я использовал эту архитектуру и радовался. Два небольших файла, myStore.js и dispatcher.js, — и в них весь однонаправленный поток данных, без единой строчки, про которую я не знал бы, зачем она там.

Попытка сделать библиотеку, Flux + createContext

Я продолжал использовать Flux, но со временем он разросся и мне захотелось попробовать написать свою npm библиотеку. Я этого ни разу не делал, но это был вызов, который хотелось принять: в финальной точке я мог бы получить то, что сгодится как альтернатива нынешним менеджерам. Да и просто интересно.

Итак, я начал — поначалу весьма неумело — выносить всю логику в более удобный формат. В первых версиях я решил отказаться от привычного Flux-подхода и попробовать совместить его возможности с React — createContext и компонент-провайдер.

Начну с простого примера createContext, который вы, возможно, видели или писали сами:

// MyProvider.jsimport { createContext, useState } from "react";const MyContext = createContext(null);function MyProvider({ children }) {  const [hero, setHero] = useState({    name: "Duncan",    role: "knight",  });  return (    <MyContext.Provider value={{ hero, setHero }}>      {children}    </MyContext.Provider>  );}

Этот способ позволяет хранить данные внутри контекста и получить к ним доступ напрямую из любого дочернего компонента:

function App() {  return (    <MyProvider>      <Header />      <Sidebar />      <Profile />    </MyProvider>  );}
// Profile.jsimport { useContext } from "react";function Profile() {  const { hero, setHero } = useContext(MyContext);  return (    <>      <h1>Hello, {hero.name}</h1>      <button onClick={() => setHero({ name: "Artorias", role: "knight" })}>        Change name      </button>    </>  );}

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

Контекст перерисовывает всех потребителей при любом изменении. React не умеет подписываться на часть значения: он сравнивает value целиком. Если компонент вызвал useContext, он перерисуется на каждое обновление контекста — даже если читает оттуда одно поле, которое не менялось.

Причём в нашем примере value={{ hero, setHero }} — это новый объект на каждом рендере провайдера. То есть перерисовка уходит всем потребителям вообще всегда, независимо от того, менялся ли hero.

И memo здесь не спасает: он сравнивает пропсы, а useContext читает значение в обход пропсов. Компонент в memo всё равно перерисуется, если контекст, который он читает, обновился.

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

Так что остаются ровно те проблемы, которые мы уже решили в своей Flux-реализации: получать состояние независимо от компонента, подписываться только на нужную часть и менять его через единый интерфейс. Давайте теперь попробуем объединить Flux и useContext:

// MyProvider.jsimport { useRef } from "react";let state = {  name: "Duncan",  role: "knight",};function MyProvider({ children }) {  const subscribers = useRef(new Set());  const setState = (newState) => {    state = {      ...state,      ...newState,    };    subscribers.current.forEach((callback) => callback());  };  return (    <MyContext.Provider      value={{        getState: () => state,        setState,        subscribe: (callback) => {          subscribers.current.add(callback);          return () => subscribers.current.delete(callback);        },      }}    >      {children}    </MyContext.Provider>  );}

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

Правда, начальные данные пока зашиты в модуль — как пользователь библиотеки задаст свои, непонятно. Запомним этот вопрос — скоро вернёмся.

А вот дальше начинается разница. Компоненты больше не читают данные напрямую из контекста — в контексте лежат только getState, setState и subscribe, то есть интерфейс стора, а не само состояние. Данные компонент получает через подписку. Давайте снова создадим хук useStore с помощью useSyncExternalStore, но брать будем из контекста:

// useStore.jsimport { useContext, useSyncExternalStore } from "react";export function useStore() {  const { getState, subscribe } = useContext(MyContext);  return useSyncExternalStore(subscribe, getState);}```И долгожданный пример использования:```jsx// Profile.jsimport { useContext } from "react";import { useStore } from "./useStore";function Profile() {  const { name } = useStore(); // реактивная переменная  const { setState } = useContext(MyContext); // способ изменять данные  return (    <>      <h1>Hello, {name}</h1>      <button onClick={() => setState({ name: "Artorias" })}>        Change name      </button>    </>  );}

Значение контекста теперь не меняется при обновлении данных — меняется состояние рядом с ним, а компоненты узнают об этом через subscribe. Та самая проблема с перерисовкой всех потребителей отпала. И это уже неплохая реализация, которая работает.

Где эта конструкция разваливается

Выглядит рабочим. Но посмотрите внимательно, где что лежит:

let state = { ... };                      // снаружи компонентаfunction MyProvider({ children }) {  const subscribers = useRef(new Set());  // внутри компонента

state объявлен на уровне модуля: он один на всё приложение и живёт, пока жив провайдер. subscribers создан через useRef: он свой у каждого экземпляра MyProvider и умирает вместе с ним.

Пока провайдер один, это незаметно, но если смонтировать два:

<MyProvider>  <Profile />    {/* один набор подписчиков */}</MyProvider><MyProvider>  <Sidebar />    {/* другой набор подписчиков */}</MyProvider>

Оба видят одни и те же данные — state общий. Но setState, вызванный внутри первого провайдера, разбудит только его подписчиков. Sidebar во втором об изменении не узнает и останется с устаревшими данными на экране.

Ошибка не в одной из половин. Ошибка в том, что **две половины одного стора живут в разных местах и с разным временем жизни**. Стор — это не «данные» и отдельно «подписчики». Стор — это данные вместе с подписчиками на них. Они должны рождаться вместе и вместе умирать.

Есть и вторая сторона той же проблемы, менее заметная: объект в value пересоздаётся на каждом рендере провайдера, а значит subscribe каждый раз новая функция, и useSyncExternalStore вынужден отписываться и подписываться заново.

Переносим состояние внутрь

Решение напрашивается: раз подписчики живут внутри провайдера, пусть и состояние живёт там же. Заодно решается вопрос, который я до сих пор обходил стороной — а как пользователь библиотеки задаст свои начальные данные? Через пропс:

// MyProvider.jsimport { useRef } from "react";function MyProvider({ children, initialState }) {  const stateRef = useRef(initialState);  const subscribers = useRef(new Set());  const setState = (newState) => {    stateRef.current = {      ...stateRef.current,      ...newState,    };    subscribers.current.forEach((callback) => callback());  };  return (    <MyContext.Provider      value={{        getState: () => stateRef.current,        setState,        subscribe: (callback) => {          subscribers.current.add(callback);          return () => subscribers.current.delete(callback);        },      }}    >      {children}    </MyContext.Provider>  );}

Тут важна одна тонкость. Просто переприсваивать параметр нельзя:

function MyProvider({ children, state }) {  const setState = (newState) => {    state = { ...state, ...newState }; // правка живёт до следующего рендера  };

React вызывает компонент заново на каждый рендер, и параметр читается из пропсов с нуля — всё, что мы в него записали, пропадает. Нужно место, которое рендер переживает, и это useRef.

Теперь всё честно: свои данные мы передаём снаружи, а провайдер стал независимым.

const state = {  name: "Duncan",  role: "knight",};<MyProvider initialState={state}>  <Profile /></MyProvider>;

На этом подходе я и строил две свои первые версии библиотеки.

Переход на Фабрики

Работает. Но посмотрите, что у нас получилось: весь стор живёт внутри React-компонента.

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

Да и проблема с тем, что объект в value по-прежнему пересоздаётся на каждом рендере, никуда не делась.

А ведь от React здесь нужен ровно один кусочек — useRef, чтобы что-то пережило рендер. Но если стор не будет жить внутри компонента, то и переживать будет нечего: обычная переменная в замыкании и так живёт столько, сколько нужно.

Вынесем всё из компонента в обычную функцию:

function createStore(initial) {  let state = initial;  const listeners = new Set();  return {    getState: () => state,    setState: (next) => {      state = { ...state, ...next };      listeners.forEach((listener) => listener());    },    subscribe: (listener) => {      listeners.add(listener);      return () => listeners.delete(listener);    },  };}

Такую функцию называют фабрикой. Этот подход к написанию кода хорошо описан в книге «Learning JavaScript Design Patterns», которая, по иронии, попалась мне позже, чем я столкнулся с самой концепцией.

Всё становится просто: вызвали — получили стор. Собственное состояние, собственные подписчики, стабильная ссылка на объект, которая не меняется от рендера к рендеру. Нужен второй, независимый — вызвали ещё раз. Немного напоминает Flux, но как будто бы он стал переиспользуемым.

Именно к этому я в итоге и пришёл.

Что даёт замыкание

Фабрика выше решает проблему с временем жизни, но пока умеет ровно то же, что и прежний стор. Зато теперь у неё есть место, куда складывать всё остальное: state и listeners — это просто переменные внутри вызова функции, и рядом с ними можно объявить сколько угодно других.

Начнём с того, ради чего всё затевалось. Помните претензию к подъёму состояния наверх — перерисовывается всё поддерево, включая непричастные компоненты? Наш стор пока ведёт себя так же: listeners — один набор, и setState будит вообще всех подписчиков, даже если изменилось одно поле.

Чтобы будить только нужных, набор подписчиков надо разложить по ключам:

function createStore(initial) {  let state = initial;  const listeners = new Map(); // ключ -> набор подписчиков на него  const notify = (keys) => {    // сначала собираем, кого звать: подписчик на несколько ключей    // должен получить одно уведомление, а не по одному на ключ    const called = new Set();    keys.forEach((key) => {      listeners.get(key)?.forEach((listener) => called.add(listener));    });    listeners.get("*")?.forEach((listener) => called.add(listener));    called.forEach((listener) => listener(state));  };  const get = (key) => (key === undefined ? state : state[key]);  const set = (next) => {    const prev = state;    state = { ...state, ...next };    // уведомляем только по тем ключам, что действительно изменились    const changed = Object.keys(next).filter((key) => prev[key] !== state[key]);    if (changed.length) notify(changed);  };  const subscribe = (listener, keys = ["*"]) => {    keys.forEach((key) => {      if (!listeners.has(key)) listeners.set(key, new Set());      listeners.get(key).add(listener);    });    return () => keys.forEach((key) => listeners.get(key)?.delete(listener));  };  return { get, set, subscribe };}

Изменилось немного — Set стал Map, — а поведение стало другим:

const store = createStore({ name: "Duncan", role: "knight", level: 3 });store.subscribe(onName, ["name"]);store.subscribe(onLevel, ["level"]);store.set({ name: "Artorias" }); // onName вызван, onLevel — нетstore.set({ name: "Artorias" }); // то же значение: не вызван никто

Три вещи разом. Компонент, читающий level, не перерисуется от смены имени: полоска опыта не мигнёт оттого, что игрок переименовал героя. Запись того же значения вообще никого не разбудит. А подписчик, следящий сразу за name и level, на обновление обоих ключей получит одно уведомление, а не два — ради этого в notify и собирается промежуточный called.

Действия живут там же

Вторая вещь, которой не хватало. Помните dispatcher? Он работал, но жил отдельно от стора и знал о state через модульную переменную — ту самую проблему с разными областями видимости мы как раз и лечим.

Раз фабрика — это функция, ей можно передать не только начальное состояние, но и описание действий. А она вызовет его и отдаст внутрь get и set:

function createStore({ state: initial, acts }) {  let state = initial;  const listeners = new Map();  // ... notify, get, set, subscribe — как выше ...  // действия рождаются здесь же и замыканием получают get/set  const actions = acts ? acts(get, set) : {};  return { get, set, subscribe, acts: actions };}

Пользоваться этим приятно: ничего не надо связывать руками, действие уже знает свой стор. Так мы подходим к финальной реализации:

const store = createStore({  state: { level: 1 },  acts: (get, set) => ({    levelUp() {      set({ level: get("level") + 1 });    },    boost(n) {      set({ level: get("level") + n });    },  }),});store.acts.levelUp();store.acts.boost(10);store.get("level"); // 12

Сравните с dispatcher: там было действие-объект, switch по строковому type и отдельный файл, который должен был как-то добраться до состояния. Здесь — обычные функции, которым состояние доступно по факту рождения.

И заметьте, чего мы не писали. Нет регистрации, нет списка типов, нет проводов между файлами. Всё, что нужно стору, создаётся одним вызовом и остаётся внутри.

createNexus

Теперь подойдём вплотную к самой библиотеке. Всё, что мы писали до сих пор, — упрощённый, хоть и подробный, пересказ того, что происходит у неё внутри. Фабрика там называется createNexus, и выглядит её использование почти так же, как наш последний пример:

import { createNexus } from "nexus-state";const nexus = createNexus({  state: { level: 1 },  acts: (get, set) => ({    levelUp() {      set({ level: get("level") + 1 });    },  }),});nexus.acts.levelUp();nexus.get("level"); // 2

Что возвращает фабрика

Вызов отдаёт шесть вещей — и все они замыкания над одним и тем же вызовом, ровно как в нашей учебной версии:

const { get, set, subscribe, reset, middleware, acts } = nexus;

get — прочитать состояние целиком или по одному ключу.

nexus.get(); // { level: 2 }nexus.get("level"); // 2

set — записать часть состояния объектом или функцией от текущего. Вторым аргументом можно указать источник обновления, о нём чуть ниже.

nexus.set({ level: 5 });nexus.set((state) => ({ level: state.level + 1 }));

subscribe — подписаться на изменения выбранных ключей. Возвращает функцию отписки. Ключи указываются явно, а ["*"] означает «все».

const off = nexus.subscribe((state) => render(state), ["level"]);

reset — вернуть состояние к исходному: целиком или по отдельным ключам.

nexus.reset("level"); // только уровеньnexus.reset(); // всё состояние

middleware — вклиниться между «хочу обновить» и «обновилось». Тоже возвращает функцию снятия.

const stop = nexus.middleware((prev, next, context) => {  console.log(context?.source, prev.level, "=>", next.level);});

acts — ваши действия, те самые из конфига.

nexus.acts.levelUp();

Что лежит внутри

До сих пор мы смотрели на библиотеку снаружи. Заглянем под капот — это уже не API, а устройство: те самые переменные в замыкании, только теперь их больше, чем в нашей учебной фабрике.

function createNexus(options) {  const frozenInitial = snapshot(options.state); // нетронутая копия для reset  let state = snapshot(options.state); // живое состояние  const listeners = new Map(); // подписчики по ключам  const localMiddleware = []; // перехватчики обновлений  let batchDepth = 0; // счётчик вложенности действий  const pendingKeys = new Set(); // что накопилось за батч  let currentActionName; // какое действие сейчас идёт  // ...}

Каждая строчка здесь — ответ на какую-то потребность, и каждая возможна только потому, что у стора есть своё пространство.

frozenInitial — чтобы reset возвращал к исходному состоянию, а не к тому, что случайно осталось. localMiddleware — чтобы было куда складывать перехватчики. batchDepth с pendingKeys — чтобы действие, сделавшее пять set подряд, разбудило подписчиков один раз, а не пять.

Последнее стоит пояснить. Действия в createNexus обёрнуты: пока действие выполняется, счётчик поднят, изменения копятся, и уведомление уходит одно — на выходе. Отсюда же берётся currentActionName: обновление, сделанное внутри действия, автоматически помечается его именем.

Вот тут и появляется то, что отличает nexus-state от остальных.

Откуда пришло обновление

У каждого обновления может быть источник, и стор его запоминает:

nexus.set({ hero }, "server");// расширенный вариант с meta даннымиnexus.set({ theme }, { source: "storage", meta: { restoredAt: Date.now() } });

Явно указанный источник доходит и до подписчиков, и до middleware:

nexus.subscribe(  (state, context) => {    console.log("изменилось из", context?.source);  },  ["hero"],);

Звучит как мелочь, пока не столкнёшься с задачей, где без этого не обойтись.

Классическая — сохранение состояния. Стор пишет данные в localStorage при изменении и читает их при старте. Но чтение — это тоже set, а значит подписчик на запись сработает и тут же запишет обратно то, что только что прочитал. Эхо. Обычно с ним борются флагами вроде isHydrating, которые надо не забыть поставить и снять.

Так мы подбираемся к persist. Если у обновления есть происхождение, флаг не нужен: persist просто не реагирует на то, что пришло от него самого. Со стороны это выглядит так:

import { createNexus, persist } from "nexus-state";const nexus = createNexus({  state: { name: "Duncan", role: "knight", level: 1 },});// сохраняем только имя и класс, уровень пусть живёт лишь в памятиpersist(nexus, { key: "hero", include: ["name", "role"] });

Одна строка — и состояние переживает перезагрузку страницы. Ни флагов, ни проверок не нужно.

Сам persist построен на обычной подписке — он просто смотрит, откуда пришло обновление. А для случаев, когда нужно вмешаться до записи, в замыкании лежит localMiddleware. Middleware — это функция, которую зовут между «хочу обновить» и «обновилось», и она видит оба состояния вместе с источником:

const stop = nexus.middleware((prev, next, context) => {  console.log(context?.source, prev.level, "=>", next.level);});nexus.set({ level: 10 }, "server"); // server 1 => 10stop(); // middleware можно снять

Если вернуть из неё состояние, оно заменит то, что собирались записать, — а вернув prev, обновление можно и вовсе отменить.

Адаптер к Redux DevTools устроен так же просто, через подписку: в панели видно не безликое SET_STATE, а имя действия, потому что оно проставилось само — тем самым currentActionName из замыкания фабрики.

import { devtools } from "nexus-state/devtools";devtools(nexus, { name: "hero" });

Возвращаемся к React

Ядро готово — и, как и Flux в самом начале, оно ничего не знает про React. Но статья началась с React-задачи, и пора её закрыть.

Хуки живут за отдельной точкой входа:

import { createReactNexus } from "nexus-state/react";const nexus = createReactNexus({  state: { name: "Duncan", role: "knight" },  acts: (get, set) => ({    rename(name) {      set({ name });    },  }),});

createReactNexus — та же фабрика. Внутри она вызывает createNexus и добавляет к результату три хука, замкнутых на тот же стор:

return {  ...nexus, // всё, что возвращает createNexus  use,  useSelector,  useRerender,};

Поэтому у неё есть всё, что было у ядра — get, set, subscribe, reset, acts, middleware — плюс React-часть.

А дальше тот самый пример, с которого всё начиналось:

function Header() {  const name = nexus.use("name");  return <h1>Hello, {name}</h1>;}function Sidebar() {  const name = nexus.use("name");  return <aside>{name}</aside>;}function Profile() {  return (    <button onClick={() => nexus.acts.rename("Artorias")}>Change name</button>  );}
function App() {  return (    <>      <Header />      <Sidebar />      <Profile />    </>  );}

Обратите внимание, чего здесь нет. Нет провайдера. Дерево не обёрнуто ни во что: стор существует сам по себе, а компоненты подписываются на него напрямую. Не нужно ни поднимать состояние наверх, ни тянуть его вниз пропсами.

И Profile, который имя не читает, при его смене не перерисуется — он просто ни на что не подписан. use("name") — это подписка ровно на один ключ, та самая Map из раздела про замыкание.

Когда нужно не поле, а что-то производное от состояния, есть useSelector:

function Greeting() {  const fullName = nexus.useSelector((state) => `${state.name} ${state.role}`);  return <span>{fullName}</span>;}

Ключи, которые селектор прочитал, отслеживаются автоматически — список зависимостей передавать не нужно.

Почему это отдельный импорт

createNexus лежит в nexus-state, а createReactNexus — в nexus-state/react. Разделение не косметическое.

Ядро не импортирует React вообще: у пакета ноль зависимостей, и createNexus работает в любом окружении — в Node, в воркере, в тесте без рендерера. React указан как опциональный peer и нужен только тем, кто берёт вторую точку входа.

Это ровно та мысль, с которой всё начиналось — «Flux не знает, что React существует», — только доведённая до устройства пакета. React-хуки здесь не часть стора, а слой поверх: createReactNexus вызывает createNexus и добавляет три функции, которые уже работают с React.

Круг замкнулся. Та задача из пролога — показать одно имя в трёх компонентах — решается тремя строчками, и я знаю, что происходит в каждой из них.

Что осталось за кадром

Библиотека поставляет createActs, который позволяет разложить действия по файлам, не теряя ни типов, ни доступа друг к другу. Есть рецепты для Immer и для SSR в Next.js.

И отдельно — типы. Я обещал не трогать типизацию, и не трогал, но скажу одну вещь: состояние и действия выводятся из конфига, так что генерики руками почти не пишутся. get("level") вернёт число, а не any, потому что библиотека уже знает форму вашего состояния. Глубокая типизация — одна из тех вещей, которые делают эту библиотеку действительно удобной.

Итог

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

Также добавлю: название вещей важнее, чем количество возможностей. Самая ценная часть nexus-state — не фичи, а то, что у каждого обновления есть источник. Это одно решение убрало целый класс проблем, которые обычно затыкают флагами.

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

Библиотека лежит в [npm], исходники — [на GitHub]. Также можно почитать [документацию].

Если дочитали досюда — спасибо, и пусть ваш Nexus всегда будет под рукой.

ссылка на оригинал статьи https://habr.com/ru/articles/1087264/