Zustand.js - современный, невесомый, производительный и очень гибкий state manager.
Статья является расшифровкой части доклада.
И так начнем с официального описания:
Zustand - не большое, быстрое и масштабируемое решение для управления состоянием, основанное на принципах Flux и immutable state. Имеет удобный API, основанный на хуках, не создает лишнего шаблонного кода и не навязывает жестких правил использования. Не имеет проблем с Zombie children и context loss и отлично работает в React concurrency mode.
Zustand работает в любой окружении:
- в коде React
- в ванильном JavaScript
Его архитектура базируется на publish/subscribe объекте и реализации единственного хука для React.
Легковесный
Тут даже нечего добавить - 693 байта! Не знаю можно ли найти реализацию еще меньше чем эта.
Простой
Zustand имеет простой синтаксис создания хранилища:
const storeHook = create((set, get) => { ... 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 хук, который используем для реактивного отображения изменений данных в компонентах.
Хранилище можно создавать и изменять как в контексте приложения 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 <button onClick={increment}>{count}</button>;
}
Удобный
Хук для удобства имеет статические методы (методы объекта pub/sub) :
{
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 <button onClick={onClick}>{count}</button>;
}
В хранилище можно использовать асинхронные вызовы:
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 <button onClick={onClick}>{count}</button>;
}
Методы можно создавать и вне хранилища
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 <div>{count}</div>;
}
- если селектор имеет параметры - заключите его в useCallback - таким образом мы обеспечим постоянство ссылки на селектор в зависимости от значения параметра
function TodoItem({ id }) {
const selectTodo = useCallback((state) => state.todos[id], [id]);
const todo = useTodoStore(selectTodo);
return (
<li>
<span>{todo.id}</span>
<span>{todo.text}</span>
</li>
);
}
- если селектор возвращает каждый раз новый объект - оберните вызов селектора в useShallow - если в новом объекте сами данные реально не изменились - хук useShallow выдаст ссылку на ранее сохраненный объект
import { useShallow } from 'zustand/react/shallow';
const selector = (state) => Object.keys(state);
function StoreInfo() {
const entityNames = useSomeStore(useShallow(selector));
return (
<div>
<div>{entityNames.join(', ')}</div>
</div>
);
}
если же селектор имеет и параметры и возвращает новый объект - оберните его в 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 (
<li>
<span>{fieldNames.join(', ')}</span>
</li>
);
}
Есть еще один "турбо" способ оптимизации отображения изменения данных в хранилище минуя цикл рендера 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 (
<div>
<span>Count: </span>
<span ref={counterRef}>{countRef.current}</span>
</div>
);
}
При инициализации компонента запоминаем актуальное состояние в мутабельном объекте 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 в своих проектах - вы будете приятно удивлены простотой и удобством работы с ним.
Top comments (0)