Хочу посвятить эту статью погружению в разные возможности обновления данных в 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/