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

Хочу посвятить эту статью погружению в разные возможности обновления данных в 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.js
import { 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.js
import { 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.js
const handlers = new Set(); // обработчики действий
// создаём dispatcher
export const dispatcher = {
register(handler) {
handlers.add(handler);
return () => handlers.delete(handler);
},
// notify передаём дальше, чтобы обработчик сам решал, когда уведомлять
dispatch(action, notify) {
handlers.forEach((handler) => handler(action, notify));
},
};Теперь добавляем dispatcher к нашему Flux:
// myStore.js
import { dispatcher } from "./dispatcher";
let state = {
name: "Duncan",
role: "knight",
};
const listeners = new Set();
// регистрируем варианты использования здесь — потому что здесь живёт state
dispatcher.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.js
import { 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.js
import { 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.js
import { 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.js
import { useContext, useSyncExternalStore } from "react";
export function useStore() {
const { getState, subscribe } = useContext(MyContext);
return useSyncExternalStore(subscribe, getState);
}
```
И долгожданный пример использования:
```jsx
// Profile.js
import { 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.js
import { 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"); // 2set — записать часть состояния объектом или функцией от текущего. Вторым аргументом можно указать источник обновления, о нём чуть ниже.
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 => 10
stop(); // 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 всегда будет под рукой.

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.