Skip to main content

Как работает Zustand

· 9 min read

Hello world!

Zustand (читается как "цуштанд", что переводится с немецкого как "состояние") - это, на мой взгляд, один из лучших на сегодняшний день инструментов для управления состоянием приложений, написанных на React.

В этой статье я немного расскажу о том, как он работает.

Давайте начнем с примера использования zustand для реализации функционала отображения/скрытия модального окна.

Код проекта лежит здесь.

Демо:


Для работы с зависимостями я буду использовать Yarn.

Создаем шаблон React-приложения с помощью Vite:

# zustand-test - название приложения
# react-ts - шаблон проекта, в данном случае React
yarn create vite zustand-test --template react

Переходим в созданную директорию, устанавливаем основные зависимости и запускаем сервер для разработки:

cd zustand-test
yarn
yarn dev

Устанавливаем дополнительные зависимости:

yarn add zustand use-sync-external-store react-use
  • react-use - большая коллекция кастомных хуков;
  • use-sync-external-store - об этом мы поговорим чуть позже.

Определяем хук для управления состоянием модалки в файле hooks/useModal.js:

import { create } from 'zustand'

const useModal = create((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}))

export default useModal

Определяем компонент модалки в файле components/Modal.jsx:

import { useEffect, useRef } from 'react'
import { useClickAway } from 'react-use'
import useModal from '../hooks/useModal'

export default function Modal() {
// состояние модалки
const modal = useModal()
// ссылка на модалку
const modalRef = useRef(null)
// ссылка на содержимое модалки
const modalContentRef = useRef(null)

useEffect(() => {
if (!modalRef.current) return

// показываем/скрываем модалку в зависимости от значения индикатора `isOpen`
// `showModal` и `close` - это нативные методы, предоставляемые HTML-элементом `dialog`
if (modal.isOpen) {
modalRef.current.showModal()
} else {
modalRef.current.close()
}
}, [modal.isOpen])

// скрываем модалку при клике за пределами ее содержимого
useClickAway(modalContentRef, modal.close)

if (!modal.isOpen) return null

return (
<dialog
style={{
padding: 0,
}}
ref={modalRef}
>
<div
style={{
padding: '1rem',
display: 'flex',
alignItems: 'center',
gap: '1rem',
}}
ref={modalContentRef}
>
<div>Modal content</div>
<button onClick={modal.close}>X</button>
</div>
</dialog>
)
}

Определяем минимальные стили в файле index.css:

body {
margin: 0;
}

#root {
min-height: 100vh;
display: grid;
place-content: center;
}

dialog::backdrop {
background-color: rgba(0, 0, 0, 0.4);
}

Наконец, рендерим модалку в файле App.jsx:

import Modal from './components/Modal'
import useModal from './hooks/useModal'

function App() {
const modal = useModal()

return (
<>
<button onClick={modal.open}>Open modal</button>
<Modal />
</>
)
}

export default App

Это было легко, не правда ли? А все благодаря магии функции create😏


Исходный код zustand находится здесь. Поскольку мы будем рассматривать только основной функционал, предоставляемый этим пакетом, нас интересует 2 файла - vanilla.ts и react.ts.

Код, содержащийся в файле vanilla.ts, представляет собой реализацию паттерна "Издатель/подписчик" (publisher/subscriber, pub/sub).

Создаем файл zustand/vanilla.js следующего содержания:

const createStoreImpl = (createState) => {
// состояние
let state
// обработчики
const listeners = new Set()

// функция обновления состояния
const setState = (partial, replace) => {
// следующее состояние
const nextState = typeof partial === 'function' ? partial(state) : partial
// если состояние изменилось
if (!Object.is(nextState, state)) {
// предыдущее/текущее состояние
const previousState = state
// обновляем состояние с помощью `nextState` (если `replace === true` или значением `nextState` является примитив)
// или нового объекта, объединяющего `state` и `nextState`
state =
replace ?? typeof nextState !== 'object'
? nextState
: Object.assign({}, state, nextState)
// запускаем обработчики
listeners.forEach((listener) => listener(state, previousState))
}
}

// функция извлечения состояния
const getState = () => state

// функция подписки
// `listener` - обработчик `onStoreChange`
// см. код хука `useSyncExternalStoreWithSelector` - об этом чуть позже
const subscribe = (listener) => {
// добавляем/регистрируем обработчик
listeners.add(listener)
// возвращаем функцию отписки
return () => listeners.delete(listener)
}

// функция удаления всех обработчиков
const destroy = () => {
listeners.clear()
}

const api = { setState, getState, subscribe, destroy }
// инициализируем состояние
state = createState(setState, getState, api)
// возвращаем методы
return api
}

// в зависимости от того, передается ли функция инициализации состояния,
// возвращаем либо `api`, либо функцию `createStoreImpl`
export const createStore = (createState) =>
createState ? createStoreImpl(createState) : createStoreImpl

Думаю, здесь все понятно. Двигаемся дальше.

Код, содержащийся в файле react.ts, представляет собой интеграцию или внедрение рассмотренного pub/sub в React fiber.

Создаем файл zustand/react.js следующего содержания:

// `CommonJS`
import useSyncExternalStoreExports from 'use-sync-external-store/shim/with-selector'
import { createStore } from './vanilla.js'

const { useSyncExternalStoreWithSelector } = useSyncExternalStoreExports

export function useStore(api, selector = api.getState, equalityFn) {
// получаем часть (срез) состояния
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getState,
selector,
equalityFn,
)
// и возвращаем его
return slice
}

const createImpl = (createState) => {
// получаем методы, возвращаемые функцией `createStore`
const api =
typeof createState === 'function' ? createStore(createState) : createState

// определяем хук, вызывающий хук `useStore` с переданной
// функцией-селектором (`selector`) для извлечения части состояния и
// функцией сравнения (`equalityFn`) для определения необходимости повторного рендеринга
const useBoundStore = (selector, equalityFn) =>
useStore(api, selector, equalityFn)

// это нужно для того, чтобы иметь возможность
// вызывать хук за пределами компонента -
// `useModal.getState()`
Object.assign(useBoundStore, api)

return useBoundStore
}

// можно получить либо хук `useBoundStore`, либо функцию `createImpl`
export const create = (createState) =>
createState ? createImpl(createState) : createImpl

Попробуйте заменить import { create } from 'zustand' на import { create } from '../zustand/react' в useModal.js и убедитесь, что с точки зрения функционала ничего не изменилось.

Вот где начинается магия😉

Хук useSyncExternalStoreWithSelector - это продвинутая версия хука useSyncExternalStore (useSyncExternalStore и его разновидности почему-то лежат в отдельном пакете). Разница между ними состоит в том, что useSyncExternalStoreWithSelector принимает 2 дополнительных параметра:

  • selector - функция-селектор для извлечения части состояния (по умолчанию возвращается все состояние);
  • equalityFn - функция для сравнения текущего и нового состояний, которая используется для определения необходимости повторного рендеринга.

Вызов useModal с селектором:

const isModalOpen = useModal((state) => state.isOpen)

Вызов useModal с селектором и функцией сравнения:

import { shallow } from 'zustand/shallow'

const { open, close } = useModal(({ open, close }) => ({ open, close }), shallow)

Для чего нужен хук useSyncExternalStore?

Как правило, компоненты React читают данные из пропов, состояния и контекста. Однако иногда компоненту может потребоваться прочитать меняющиеся со временем данные из хранилища, находящегося за пределами React. Таким хранилищем может быть:

  • сторонняя библиотека для управления состоянием (такая как zustand), которая хранит состояние за пределами React;
  • браузерный API, предоставляющий мутируемое значение и события для подписки на его изменения.

useSyncExternalStore принимает 2 обязательных и 1 опциональный параметр:

  • subscribe (обязательный параметр) - функция, принимающая параметр callback и выполняющая подписку на хранилище. callback вызывается при любом изменении хранилища. Это приводит к повторному рендерингу компонента. subscribe должна возвращать функцию отписки от хранилища;
  • getSnapshot (обязательный параметр) - функция, возвращающая снимок (snapshot) состояния из хранилища, потребляемого компонентом. Если состояние не изменилось, повторные вызовы getSnapshot должны возвращать одинаковые значения. Если новое состояние отличается от текущего, React выполняет повторный рендеринг компонента;
  • getServerSnapshot (опциональный параметр) - функция, возвращающая начальный снимок состояния из хранилища. Она используется только в процессе серверного рендеринга контента и его гидратации на клиенте.

useSyncExternalStore возвращает снимок хранилища для использования в логике (цикле) рендеринга React.

Подробнее о рассматриваемом хуке можно почитать в этой статье.

Таким образом, useSyncExternalStore позволяет подписываться на изменения состояния, находящегося во внешнем хранилище, способом, совместимым с конкурентными возможностями React. Цикл рендеринга, в числе прочего, предполагает вызов одинаковой для первоначального и повторных рендерингов последовательности хуков, используемых компонентом. Одинаковая последовательность вызова (и количество) хуков обеспечиваются правилами использования хуков. Это логично: вызов хуков в другой последовательности или в меньшем/большем количестве приведет к несогласованности состояния компонента.

useSyncExternalStore делает наш pub/sub (внешнее хранилище) частью системы хуков, формирующей итоговое состояние компонента.

Код рассматриваемого хука можно найти здесь (функции mountSyncExternalStore и следующая за ней updateSyncExternalStore).

"Голая" mountSyncExternalStore выглядит так:

function mountSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
) {
// волокно
const fiber = currentlyRenderingFiber
// текущий/выполняемый хук
const hook = mountWorkInProgressHook()

// следующий снимок состояния
let nextSnapshot
const isHydrating = getIsHydrating()
if (isHydrating) {
nextSnapshot = getServerSnapshot()
} else {
nextSnapshot = getSnapshot()
}

// читаем текущий снимок из хранилища на каждом рендеринге
// это нарушает обычные правила React и работает только благодаря тому,
// что обновления хранилища всегда являются синхронными
hook.memoizedState = nextSnapshot
const inst = {
value: nextSnapshot,
getSnapshot,
}
hook.queue = inst

// здесь планируются эффекты для подписки на хранилище и
// для обновления мутируемых полей экземпляра (`inst`),
// которые обновляются при любом изменении `subscribe`, `getSnapshot` или значения
// эти внутренности нас не интересуют

return nextSnapshot
}

Отличия updateSyncExternalStore от mountSyncExternalStore сводятся к следующему:

// предыдущий снимок
const prevSnapshot = (currentHook || hook).memoizedState;
// изменилось ли состояние?
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
// если изменилось
if (snapshotChanged) {
hook.memoizedState = nextSnapshot;
markWorkInProgressReceivedUpdate();
}
const inst = hook.queue;

В качестве бонуса ловите слегка видоизмененную функцию shallow, позволяющую глубоко сравнивать объекты, которой можно найти большое количество применений:

function equal<T>(objA: T, objB: T): boolean {
if (Object.is(objA, objB)) {
return true
}

if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false
}

if (
(Array.isArray(objA) && !Array.isArray(objB)) ||
(Array.isArray(objB) && !Array.isArray(objA))
) {
return false
}

if (objA instanceof Map && objB instanceof Map) {
if (objA.size !== objB.size) return false

for (const [key, value] of objA) {
if (!Object.is(value, objB.get(key))) {
return false
}
}
return true
}

if (objA instanceof Set && objB instanceof Set) {
if (objA.size !== objB.size) return false

for (const value of objA) {
if (!objB.has(value)) {
return false
}
}
return true
}

if (objA instanceof Date && objB instanceof Date) {
return Object.is(objA.getTime(), objA.getTime())
}

const keysA = Object.keys(objA)
if (keysA.length !== Object.keys(objB).length) {
return false
}

return keysA.every((key) => equal(objA[key as keyof T], objB[key as keyof T]))
}

Надеюсь, вы узнали что-то новое и не зря потратили время.

The end.