Как использовать глобальное состояние в React.js?
или как решили бы эту проблему создатели Jotai (state manager библиотеки для React) за семь дней.
Статья собрана из заметок моего блога.
День 1. Почему нельзя просто закинуть в var state = {}?
Давайте просто создадим глобальную переменную. Что может пойти не так?
let globalState = { count: 0 };
import React from 'react';
let globalState = { count: 0 };
function Counter() {
const inc = () => {
globalState.count += 1;
console.log(globalState.count);
};
return (
<div>
Count: {globalState.count}
<button onClick={inc}>+1</button>
</div>
);
}
export function App(props) {
return (
<div className='App'>
<Counter />
</div>
);
}
https://playcode.io/react-playground--019a91b8-dc29-731b-a8ce-373ae37d30e9
Всё работает! Оба компонента читают и пишут в один источник.
В консоли всё обновляется. Но почему не обновляется в браузере? Реакт же реактивный!
День 2. «Это же React, используй хуки!»
Добро, мы же в React. Надо использовать пользовательские хуки!
Напишем общий хук.
const useGlobalState = () => {
const [state, setState] = useState(0);
// ... остальной код
};
https://playcode.io/jotai-pure-usestate--019a91f7-a7f8-7058-866c-e23493602cb0
И... снова провал. Каждый компонент <Counter /> создал своё собственное, независимое состояние. Мы получили два изолированных счётчика вместо одного общего. Движемся дальше…
День 3. Используем useContext.
Хорошо, для общего состояния есть Context. Теперь-то точно должно получиться!
const GlobalStateContext = createContext(/*...*/);
// ... остальной код
https://playcode.io/jotai-pure-usestate--019a91f7-a7f8-7058-866c-e23493602cb0
Но посмотрите на DummyComponent — он перерисовывается при каждом нажатии! Классический useContext: любое изменение в провайдере вызывает перерисовку всех его дочерних элементов.
Можно проверять пропсы - то есть мемоизировать компонент. MemoedDummyComponent как раз такой и он не обновляется.
Но не оборачивать же теперь каждый компонент так!
День 4. Проблемы масштабирования и магия их решения.
Итак, что нам нужно?
Хранить состояние вне жизненного цикла React.
Подписать компоненты на изменения нужной части состояния.
Обновлять только те компоненты, которые реально зависят от изменившихся данных.
Что делать? Есть две магии в state managers для реакта!
Магия голого контекста
Обычно контекст используют, чтобы не прокидывать данные от useState вниз по дереву. Вместо этого записывать на некую шину, с которой потом можно считать.
Но! Важно знать, что useContext может работать как
const [state] = useState(initialInfo).
То есть в контекст можно положить некое значение по умолчанию и использовать его в компонентах.
А что если вспомнить частый вопрос на собесах про реализацию Event Emitter’а?
Ну то есть почитаем про шаблон проектирования: “Наблюдатель”. https://ru.wikipedia.org/wiki/Наблюдатель_(шаблон_проектирования)#JavaScript_ES6
Можно теперь хранить состояние в виде объекта с методами записи и чтения.
{
let state = { count: 0 };
const getState = () => state.count;
const setState = nextValue => {
state = { count: nextValue };
console.log(state.count);
};
return { getState, setState };
}
И читать его в компонентах как из обычного контекста, но без провайдеров.
const GlobalStateContext1 = createContext(createStore());
function Counter1() {
const { getState, setState } = useContext(GlobalStateContext1);
const count = getState();
const inc = () => setState(count + 1);
return (
<dl>
<dt>Счётчик: {count}</dt>
<dd>
<button onClick={inc}>Плюс один</button>
</dd>
</dl>
);
}
https://playcode.io/jotai-usecontext-without-provider--019a922f-bcbf-75ed-9ecb-268ff81ab73d
Однако, это возвращает нас к самой первой проблеме: код написанный в реакте ещё не делает его реактивным.
То есть getState вызовется всего лишь раз.
Можно конечно проблему решить, обвязав этот объект с каким-то хуком с useEffect и useState. Но, вдруг, это уже сделали за нас?
День 5. Тайная библиотекой реакта.
Сделали! И не абы кто, а сама команда реакта!
Библиотека use-subscription поможет сделать store реактивным вновь.
Далее покажу весь код, но призываю понять суть, а не обращать внимание на детали реализации. Повторю важные вещи.
Проблема
Нужно использовать одно состояние для нескольких компонентов.
Обычный JS объект не подходит, он не реактивен – не учитывается реактом при отрисокве.
С другой стороны, связка
useState+useContextперерисовывает всех своих слушателей (если не целое дерево под провайдером).Глобальный
useStateв хуке просто повторяет логику обычногоuseStateв компоненте.
Решение
useContextможно использовать без провайдера! Это будет равноценным по логикеconst [state] = useState(initialValue). Контекст и данные хранит в одном месте, и перерисовки не вызывает.Однако, значение контекста по умолчанию – обычный JS объект. Он не реактивен.
Попробуем хранить не просто значение, а систему работы со значениям (состояние и методы на чтение и запись). То есть использовать шаблон проектирования “Наблюдатель”.
Чтобы сделать этот объект реактивным нужно использовать библиотеку от команды реакта use-subscription. Кидаем ей методы subscribe из “Наблюдателя” и определяем поле, которое нужно слушать. На выходе получаем общеизвестный selector - часть состояния на чтение.
Итог
Получили состояние вне жизненного цикла React.
Можно подписывать компоненты на изменения нужной части состояния.
Перерисовываются только те компоненты, которые реально зависят от изменившихся данных.
import React, {
useEffect,
createContext,
useContext,
useRef,
useMemo,
} from 'react';
import { useSubscription } from 'use-subscription';
const createStore = initialState => {
let state = initialState;
const callbacks = new Set();
const getState = () => state;
const setState = nextState => {
state = typeof nextState === 'function' ? nextState(state) : nextState;
callbacks.forEach(callback => callback());
};
const subscribe = callback => {
callbacks.add(callback);
return () => {
callbacks.delete(callback);
};
};
return { getState, setState, subscribe };
};
const StoreContext = createContext(createStore({ count: 0 }));
const useSelector = selector => {
const store = useContext(StoreContext);
return useSubscription(
useMemo(
() => ({
getCurrentValue: () => selector(store.getState()),
subscribe: store.subscribe,
}),
[store, selector]
)
);
};
const useSetState = () => {
const store = useContext(StoreContext);
return store.setState;
};
const selectCount = state => state.count;
function Counter() {
const count = useSelector(selectCount);
const setState = useSetState();
const inc = () =>
setState(prev => ({
...prev,
count: prev.count + 1,
}));
return (
<dl>
<dt>Счётчик: {count}</dt>
<dd>
<button onClick={inc}>Плюс один</button>
</dd>
</dl>
);
}
const DummyComponent = () => {
const renderCount = useRef(1);
useEffect(() => {
renderCount.current += 1;
});
return <div>Dummy (renders: {renderCount.current})</div>;
};
const MemoedDummyComponent = React.memo(DummyComponent);
export function App() {
return (
<div className='App'>
<ul>
<li>
<Counter />
</li>
<li>
<Counter />
</li>
<li>
<DummyComponent />
</li>
<li>
<MemoedDummyComponent />
</li>
</ul>
</div>
);
}
https://playcode.io/jotai-usecontext-and-hidden-lib--019a925e-877d-72fa-9f0d-aefa979e5433
Это пример концепции Jotai - атомного хранилища. Редакс работает чуть иначе. Но суть та же, разница в жизненном цикле setter-getter.
Спасибо читателям за ушат комментариев под разбором принципов работы Редакса в предыдущей статье =)
Синтаксис - ничто, понимание концепций - всё!
Если вы есть, будьте первыми!
День 6. Откуда ты вообще это вычитал?!
Отличный вопрос, конечно я не такой умный, чтобы это всё придумать. Я просто трачу от месяца до полугода, копаясь в конкретной теме. Пишу заметки и готовлюсь к собесам.
Именно эту тему я понял после крутой, но малоизвестной книги “Micro State Management with React Hooks” от создателя Jotai – Daishi Kato.
Ссылка на книгу: https://www.packtpub.com/en-us/product/micro-state-management-with-react-hooks-9781801810043.
А весь код есть на гитхабе: https://github.com/PacktPublishing/Micro-State-Management-with-React-Hooks.