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.

Комментарии (2)


  1. serginho
    27.07.2023 08:21
    +1

    И всё же лучшая библиотека для управления состоянием - это MobX


    1. markelov69
      27.07.2023 08:21
      +1

      И всё же лучшая библиотека для управления состоянием - это MobX

      Истину глаголешь