Zustand — це мінімалістична бібліотека для управління станом в JavaScript додатках, оптимізована зокрема для React. Вона надає простий та ефективний спосіб створення глобального стану без необхідності використання Redux або Context API, що може бути занадто складним або обтяжливим для деяких проектів. Zustand вирізняється своєю легкістю інтеграції, високою продуктивністю та гнучкістю, дозволяючи розробникам легко створювати та управляти станом з мінімальними зусиллями. У цій статті ви знайдете продовження дослідження можливостей та переваг, які пропонує Zustand, детальний огляд його API, а також практичні приклади використання, які допоможуть вам інтегрувати цю бібліотеку у ваші React проекти.
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]))
}
Сподіваюся, ви дізналися щось нове і недаремно витратили час.