DEV Community

Cover image for Zustand.js - современный, невесомый, производительный и очень гибкий state manager
Вадим Бударин
Вадим Бударин

Posted on

Zustand.js - современный, невесомый, производительный и очень гибкий state manager

Zustand.js - современный, невесомый, производительный и очень гибкий state manager.

Статья является расшифровкой части доклада.

И так начнем с официального описания:

Zustand - не большое, быстрое и масштабируемое решение для управления состоянием, основанное на принципах Flux и immutable state. Имеет удобный API, основанный на хуках, не создает лишнего шаблонного кода и не навязывает жестких правил использования. Не имеет проблем с Zombie children и context loss и отлично работает в React concurrency mode.

Zustand работает в любой окружении:

  • в коде React
  • в ванильном JavaScript

Его архитектура базируется на publish/subscribe объекте и реализации единственного хука для React.

Легковесный

Zustand bundlephobia

Тут даже нечего добавить - 693 байта! Не знаю можно ли найти реализацию еще меньше чем эта.

Простой

Zustand имеет простой синтаксис создания хранилища:

const storeHook = create((set, get) => { ... store config ... });
Enter fullscreen mode Exit fullscreen mode

Для примера создадим простое хранилище:

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 })),
}));
Enter fullscreen mode Exit fullscreen mode

В результате создания хранилища мы получаем 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 })),
}));
Enter fullscreen mode Exit fullscreen mode

Созданный хук имеет простой синтаксис

storeHook: (selector) => selectedState;
Enter fullscreen mode Exit fullscreen mode

где selector - это простая функция для извлечению данных из переданного ей состояния хранилища

const stateData = storeHook((state) => {
    // извлечение данных  из  state
    ...
    return selectedState;
});
Enter fullscreen mode Exit fullscreen mode

Созданный React хук, легко использовать в компонентах

function Counter() {
    const { count, increment } = useCounterStore((state) => state);

    return <button onClick={increment}>{count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Удобный

Хук для удобства имеет статические методы (методы объекта pub/sub) :

{
    getState: () => state,
    setState: (newState) => void,
    subscribe: (callback) => void,
    ...
}
Enter fullscreen mode Exit fullscreen mode

Статические методы очень удобно использовать в обработчиках

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>;
}
Enter fullscreen mode Exit fullscreen mode

В хранилище можно использовать асинхронные вызовы:

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 };
        });
    },
}));
Enter fullscreen mode Exit fullscreen mode

Асинхронные вызовы так же можно выполнять и в компоненте

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>;
}
Enter fullscreen mode Exit fullscreen mode

Методы можно создавать и вне хранилища

const useCounterStore = create(() => ({
    count: 0,
}));

const increment = () => {
    const state = useCounterStore.getState();
    useCounterStore.setState({ count: state.count + 1 });
};
Enter fullscreen mode Exit fullscreen mode

В 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;
}
Enter fullscreen mode Exit fullscreen mode

В момент инициализации хука из хранилища выбирается текущее состояние для селектора и записывается в состояние хука.
Далее в эффекте производится подписка на изменения 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,
    );
}
Enter fullscreen mode Exit fullscreen mode

В Concurrency mode в случае изменения данных в хранилище React будет прерывать параллельное построение дерева DOM и будет запускать синхронный рендер для того, чтобы отобразить новые изменения данных в хранилище - это гарантирует что ни одно изменение в хранилище не будет пропущено и будет отрисовано.
Команда React работает над тем, чтобы в будущем избежать грубого прерывания параллельной работы React и выполнять своевременную отрисовку изменений в контексте конкурентного режима.

Методы оптимизации

Методы оптимизации вытекают из выше приведенного кода реализации хука - хук напрямую зависит от селектора, поэтому мы должны обеспечить постоянство ссылки на сам селектор и на данне возвращаемые им!

Их всего 3 простых метода оптимизации

  1. если селектор не зависит от внутренних переменных - вынесите его за пределы компонента, таким образом селектор не будет создаваться каждый раз и ссылка на него будет постоянной
const countSelector = (state) => state.count;

function Counter() {
    const count = useCounterStore(countSelector);
    return <div>{count}</div>;
}
Enter fullscreen mode Exit fullscreen mode
  1. если селектор имеет параметры - заключите его в 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>
    );
}
Enter fullscreen mode Exit fullscreen mode
  1. если селектор возвращает каждый раз новый объект - оберните вызов селектора в 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>
    );
}
Enter fullscreen mode Exit fullscreen mode

если же селектор имеет и параметры и возвращает новый объект - оберните его в 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>
    );
}
Enter fullscreen mode Exit fullscreen mode

Есть еще один "турбо" способ оптимизации отображения изменения данных в хранилище минуя цикл рендера 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>
    );
}
Enter fullscreen mode Exit fullscreen mode

При инициализации компонента запоминаем актуальное состояние в мутабельном объекте countRef и при перерисовках компонента отображаем его. В случае срабатывания подписки - получаем новое состояние, записываем его в мутабельный объект countRef и затем изменяем текстовое содержимое ссылки на DOM элемент counterRef.
В случае если затем React по какой-то причине будет перерисовывать наш компонент - у нас всегда будет актуальное состояние хранилища в мутабельном объекте countRef.

Лучшие практики

  1. Все связанные сущности держите в одном хранилище

Zustand позволяет создавать не ограниченное количество хранилищ, но на практике отдельное хранилище нужно создавать только для данных, которые реально не связаны с данными в других хранилищах иначе вы вернетесь в прошлое - известная проблема model hell из MVC и FLUX (большое количество моделей имеющих хаотические связи с не прозрачной логикой взаимодействия с разнонаправленными потоками данных, как правило, всегда ведущих к зацикливанию обновлений и очень долгими отладками по вечерам для обнаружения этих зацикливаний - более подробно в докладе).

В настоящее время молодое поколение разработчиков, не работавших с MVC и FLUX и не изучавших теорию, создающих различные атомные, молекулярные, протонные, нейтронные и всякие кварковые стейт-менеджеры, наступают на грабли, которые индустрия прошла много лет назад. Связанные данные должны хранится в одном хранилище.
Иначе встает вопрос: зачем вы вынесли локальное состояние из компонента Counter в отдельное хранилище? Какие преимущества кроме неудобства и дополнительного кода, с его привнесенной сложностью, это дало?

Создание отдельных хранилищь оправдано лишь для:

  • реально не зависимых структур данных
  • для данных в микрофронтах
  1. Методы хранилища создавайте вне его описания

Не смотря на то что Zustand позволяет создавать методы хранилища прямо в нем самом, рекомендую не создавать методы хранилища внутри него - если у вас в хранилище много сущностей или сущности сложные, а их методы обработки достаточно объемные - описание вашего хранилища станет не читаемым, не понимаемым и плохо поддерживаемым.

Размещение методов в отдельных модулях дает еще один бонус - кроме того что мы визуально изолируем код метода, что делает его легче для чтения и понимания, это позволяет легко тестировать метод в изоляции от самого хранилища.

  1. Доступ к данным в хранилище только при помощи его методов!

Я много лет проектировал и сопровождал базы данных, и для меня не понятно почему в подавляющем большинстве проектов часть базы данных, представленная на клиенте хранилищем, являющегося частью бизнес логики данных, не обеспечивает эту бизнес логику!
Нет ни кода по обеспечению целостности данных, ни их непротиворечивости и безопасности!
Подавляющее количество хранилищ состояния приложения, которые я видел промышленной эксплуатации - это простые 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 });
};
Enter fullscreen mode Exit fullscreen mode

Так же рекомендую производить полное клонирование данных на входе и на выходе из методов - ни один джун не сломает ваше хранилище путем мутирования данных из хранилища (для эксперимента попробуйте в возвращенных данных изменить что-либо - вы будете не приятно удивлены результатом - вы напрямую измените данные в хранилище).

Инкапсуляция бизнес-логики в методах хранилища так же позволит безболезненно менять стейт-менеджер в случае необходимости.

Заключение:

Если посмотреть на реализацию Zustand и оглянуться на 10 лет назад, когда у нас уже были активные модели типа pub/sub, и нам оставалось лишь объединить весь зоопарк этих моделей в одно хранилище и реализовать аналог хука (что элементарно реализуется в на старых классовых компонентах) можно понять, что в какой-то момент индустрия свернула не туда и мы пошли не за теми и лишь спустя 10 лет мы наконец-то вышли на правильную дорогу: однонаправленный поток данных (компонент -> реакция юзера -> изменение данных в хранилище -> хук для отображения изменений) и хранение связанных структур данных в одном хранилище.

По пути нам пришлось вначале изобрести FLUX, и затем уйти еще дальше в сторону от простого решения на Redux, параллельно на ощупь искать правильное решение при помощи тысяч реализаций стейт менеджеров.

Но хорошо то, что хорошо заканчивается!

Zustand - это просто мечта разработчика!

  • компактный
  • простой в использовании
  • не добавляющий когнитивной нагрузки
  • производительный
  • легко оптимизируемый
  • легко масштабируемый
  • имеющий легко поддерживаемый код
  • исключающий проблемы типа Zombie children и context loss
  • поддерживающий работу в React concurrency mode

Мы используем Zustand практически с момента его появления на свет и испытываем только положительные эмоции от его применения.

Начните активно использовать Zustand в своих проектах - вы будете приятно удивлены простотой и удобством работы с ним.

Top comments (0)