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.
serginho
И всё же лучшая библиотека для управления состоянием - это MobX
markelov69
Истину глаголешь