Zustand.js: современный, невесомый, производительный и очень гибкий state manager
Статья является расшифровкой части доклада.
И так начнем с официального описания:
Zustand — не большое, быстрое и масштабируемое решение для управления состоянием, основанное на принципах Flux и immutable state. Имеет удобный API, основанный на хуках, не создает лишнего шаблонного кода и не навязывает жестких правил использования. Не имеет проблем с Zombie children и context loss и отлично работает в React concurrency mode.
Zustand работает в любой окружении:
Его архитектура базируется на publish/subscribe объекте и реализации единственного хука для React.
Легковесный
Zustand bundlephobia
Тут даже нечего добавить — 693 байта! Не знаю можно ли найти реализацию еще меньше чем эта.
Простой
Zustand имеет простой синтаксис создания хранилища:
const storeHook = create((get, set) => { ... store config ... });
Для примера создадим простое хранилище:
import { create } from 'zustand';
export const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
Хранилище можно создавать и изменять как в контексте приложения React так и в javascript вне контекста React (не каждый стейт-менеджер способен на такое).
Zustand для обнаружения изменений использует иммутабельность — привычные методы работы с данными в React.
В Zustand вы можете создавать столько хранилищ, сколько вашей душе будет угодно.
import { create } from 'zustand';
export const useCatsStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
export const useDogsStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
Созданный хук имеет простой синтаксис
storeHook: (selector) => selectedState;
где selector — это простая функция для извлечению данных из переданного ей состояния хранилища
const stateData = storeHook((state) => {
// извлечение данных из state
...
return selectedState;
});
Созданный React хук, легко использовать в компонентах
function Counter() {
const { count, increment } = useCounterStore((state) => state);
return ;
}
Удобный
Хук для удобства имеет статические методы:
{
getState: () => state,
setState: (newState) => void,
subscribe: (callback) => void,
...
}
Статические методы очень удобно использовать в обработчиках
function Counter() {
const count = useCounterStore((state) => state.counter);
function onClick() {
const state = useCounterStore.getState();
useCounterStore.setState({
count: state.count + 3;
});
}
return ;
}
В хранилище можно использовать асинхронные вызовы:
const useCounterStore = create((set) => ({
count: 0,
increment: async () => {
const resp = await fetch('https://my-bank/api/getBalance');
const { balance } = await resp.json();
set((state) => {
return { count: state.count + balance };
});
},
}));
Асинхронные вызовы так же можно выполнять и в компоненте
function Counter() {
const count = useCounterStore((state) => state.counter);
function onClick() {
const resp = await fetch('https://my-bank/api/getBalance');
const { balance } = await resp.json();
useCounterStore.setState({
count: count + balance,
});
}
return ;
}
Методы можно создавать и вне хранилища
const useCounterStore = create(() => ({
count: 0,
}));
const increment = () => {
const state = useCounterStore.getState();
useCounterStore.setState({ count: state.count + 1 });
};
В Zustand как и в Redux можно создавать свои middleware!
С ним поставляются готовые middlewares:
persist: для сохранения/восстановления данных в/из localStorage
immer: для простого мутабельного изменения состояния
devtools: для работы с расширением redux devtools для отладки
Производительный
А чтоже под капотом ?
Привожу наивную реализацию хука Zustand, который отображает его логику
function useStore(selector) {
const [prevState, setPrevState] = useState(store.getState(selector));
useEffect(() => {
const unsubscribe = store.subscribe((newState) => {
const currentState = selector(newState);
if (currentState !== prevState) {
setPrevState(currentState);
}
});
return () => {
unsubscribe();
};
}, [selector]);
return prevState;
}
В момент инициализации хука из хранилища выбирается текущее состояние для селектора и записывается в состояние хука.
Далее в эффекте производится подписка на изменения pusb/sub объекта хранилища, в которой, при возникновении обновлений данных хранилища, производится выборка данных при помощи селектора. Затем производится сравнение ссылок текущих данных со ссылкой на данные, сохраненные ранее в состоянии хука. Если ссылки не равны (, а мы помним, что при иммутабельном изменении данных в объекте меняются ссылки на данные) — вызывается метод обновления состояния хука.
Куда уж проще и эффективнее?!
Сравнение ссылок это простая и очень эффективная операция
Так как данные хранилища хранятся вне контекста React (во внешнем объекте pub/sub) данный хук будет пропускать изменения состояний хранилища между рендерами в Concurrency mode в React 18+, поэтому реальная реализация хука Zustand базируется на новом хуке React для синхронной перерисовки дерева компонент
function useStore(store, selector, equalityFn) {
return useSyncExternalStoreWithSelector(
store.subscribe,
store.getState,
store.getServerState || store.getInitialState,
selector,
equalityFn,
);
}
В Concurrency mode в случае изменения данных в хранилище React будет прерывать параллельное построение дерева DOM и будет запускать синхронный рендер для того, чтобы отобразить новые изменения данных в хранилище — это гарантирует что ни одно изменение в хранилище не будет пропущено и будет отрисовано.
Команда React работает над тем, чтобы в будущем избежать грубого прерывания параллельной работы React и выполнять своевременную отрисовку изменений в контексте конкурентного режима.
Методы оптимизации
Методы оптимизации вытекают из выше приведенного кода реализации хука — хук напрямую зависит от селектора, поэтому мы должны обеспечить постоянство ссылки на сам селектор и на данне возвращаемые им!
Их всего 3 простых метода оптимизации
если селектор не зависит от внутренних переменных — вынесите его за пределы компонента, таким образом селектор не будет создаваться каждый раз и ссылка на него будет постоянной
const countSelector = (state) => state.count;
function Counter() {
const count = useCounterStore(countSelector);
return {count};
}
если селектор имеет параметры — заключите его в useCallback — таким образом мы обеспечим постоянство ссылки на селектор в зависимости от значения параметра
function TodoItem({ id }) {
const selectTodo = useCallback((state) => state.todos[id], [id]);
const todo = useTodoStore(selectTodo);
return (
{todo.id}
{todo.text}
);
}
если селектор возвращает каждый раз новый объект — оберните вызов селектора в useShallow — если в новом объекте сами данные реально не изменились — хук useShallow выдаст ссылку на ранее сохраненный объект
import { useShallow } from 'zustand/react/shallow';
const selector = (state) => Object.keys(state);
function StoreInfo() {
const entityNames = useSomeStore(useShallow(selector));
return (
{entityNames.join(', ')}
);
}
если же селектор имеет и параметры и возвращает новый объект — оберните его в useCallback и затем в useShallow
import { useShallow } from 'zustand/react/shallow';
function TodoItemFieldNames({ id }) {
const selectFieldNames = useCallback((state) => Object.keys(state.todos[id]), [id]);
const fieldNames = useTodoStore(useShallow(selectFieldNames));
return (
{fieldNames.join(', ')}
);
}
Есть еще один «турбо» способ оптимизации отображения изменения данных в хранилище минуя цикл рендера React — использовать подписку на изменения данных и манипулировать элементами DOM напрямую
function Counter() {
const counterRef = useRef(null);
const countRef = useRef(useCounterStore.getState().count);
useEffect(
() =>
useCounterStore.subscribe((state) => {
countRef.current = state.count;
counterRef.current.textContent = countRef.current;
}),
[],
);
return (
Count:
{countRef.current}
);
}
При инициализации компонента запоминаем актуальное состояние в мутабельном объекте countRef
и при перерисовках компонента отображаем его. В случае срабатывания подписки — получаем новое состояние, записываем его в мутабельный объект countRef
и затем изменяем текстовое содержимое ссылки на DOM элемент counterRef
.
В случае если затем React по какой-то причине будет перерисовывать наш компонент — у нас всегда будет актуальное состояние хранилища в мутабельном объекте countRef
.
Лучшие практики
Все связанные сущности держите в одном хранилище
Zustand позволяет создавать не ограниченное количество хранилищ, но на практике отдельное хранилище нужно создавать только для данных, которые реально не связаны с данными в других хранилищах иначе вы вернетесь в прошлое — известная проблема model hell из MVC и FLUX (большое количество моделей имеющих хаотические связи с не прозрачной логикой взаимодействия с разнонаправленными потоками данных, как правило, всегда ведущих к зацикливанию обновлений и очень долгими отладками по вечерам для обнаружения этих зацикливаний — более подробно в докладе).
В настоящее время молодое поколение разработчиков, не работавших с MVC и FLUX и не изучавших теорию, создающих различные атомные, молекулярные, протонные, нейтронные и всякие кварковые стейт-менеджеры, наступают на грабли, которые индустрия прошла много лет назад. Связанные данные должны хранится в одном хранилище.
Иначе встает вопрос: зачем вы вынесли локальное состояние из компонента Counter в отдельное хранилище? Какие преимущества кроме неудобства и дополнительного кода, с его привнесенной сложностью, это дало?
Создание отдельных хранилищь оправдано лишь для:
Методы хранилища создавайте вне его описания
Не смотря на то что Zustand позволяет создавать методы хранилища прямо в нем самом, рекомендую не создавать методы хранилища внутри него — если у вас в хранилище много сущностей или сущности сложные, а их методы обработки достаточно объемные — описание вашего хранилища станет не читаемым, не понимаемым и плохо поддерживаемым.
Доступ к данным в хранилище только при помощи его методов!
Я много лет проектировал и сопровождал базы данных, и для меня не понятно почему часть базы данных, представленная на клиенте хранилищем, являющегося частью бизнес логики данных, не обеспечивает эту бизнес логику!
Нет ни кода по обеспечению целостности данных, ни их непротиворечивости и безопасности!
Подавляющее количество хранилищ состояния приложения, которые я видел промышленной эксплуатации — это простые Json хранилки! Это не хранилище состояния, а именно простые хранилки json.
Если понять, что эти данные и есть то ядро приложения, вокруг которого строится приложение, то становится понятно, что как это хранилище реализовано — таким и будет надежность приложения. Если хранилище (ядро приложения) — это простая Json хранилка — то 99.99% что приложение будет лихорадить от большого количества багов. Так или иначе код по валидации данных будет размазан и дублирован по разным частям приложения. А отсутствие обеспечения целостности данных и их непротиворечивости гарантированно будет приводить к багам которые будут возникать постоянно в большой команде.
Чтобы создать надежное хранилище и тем самым ликвидировать большинство багов в приложении:
разработчик не должен иметь прямой доступ к внутренностям хранилища
данные должны извлекаться и обрабатываться исключительно при помощи методов хранилища
методы хранилища должны обеспечивать целостность и непротиворечивость данных
import { create } from 'zustand';
// никода не экспортируем сам хук с его методами доступа к хранилищу!
const useCounterStore = create(() => ({
count: 0,
}));
// экспортируем хук - не даем доступ ко всему содержимому хранилища
export const useCounter = () => useCounterStore((state) => state.count);
// экспортируем метод
export const increment = () => {
const state = useCounterStore.getState();
useCounterStore.setState({ count: state.count + 1 });
};
Так же рекомендую производить полное клонирование данных на входе и на выходе из методов — ни один джун не сломает ваше хранилище путем мутирования данных из хранилища (для эксперимента попробуйте в возвращенных данных изменить что-либо — вы будете не приятно удивлены результатом — вы напрямую измените данные в хранилище).
Инкапсуляция бизнес-логики в методах хранилища так же позволит безболезненно менять стейт-менеджер в случае необходимости.
Заключение:
Если посмотреть на реализацию Zustand и оглянуться на 10 лет назад, когда у нас уже были активные модели типа pub/sub
, и нам оставалось лишь объединить весь зоопарк этих моделей в одно хранилище и реализовать аналог хука (что элементарно реализуется в на старых классовых компонентах) можно понять, что в какой-то момент индустрия свернула не туда и мы пошли не за теми и лишь спустя 10 лет мы наконец-то вышли на правильную дорогу: однонаправленный поток данных (компонент → реакция юзера → изменение данных в хранилище → хук для отображения изменений) и хранение связанных структур данных в одном хранилище.
По пути нам пришлось вначале изобрести FLUX, и затем уйти еще дальше в сторону от простого решения на Redux, параллельно на ощупь искать правильное решение при помощи тысяч реализаций стейт менеджеров.
Но хорошо то, что хорошо заканчивается!
Zustand — это просто мечта разработчика!
компактный
простой в использовании
не добавляющий когнитивной нагрузки
производительный
легко оптимизируемый
легко масштабируемый
имеющий легко поддерживаемый код
исключающий проблемы типа Zombie children и context loss
поддерживающий работу в React concurrency mode
Мы используем Zustand практически с момента его появления на свет и испытываем только положительные эмоции от его применения.
Начните активно использовать Zustand в своих проектах — вы будете приятно удивлены простотой и удобством работы с ним.