Skip to main content

Zustand

Zustand - современный инструмент для управления состоянием React-приложений.

Пример использования.

Теория

Установка

yarn add zustand
# or
npm i zustand

Создание хранилища

Хранилище - это хук. В нем можно хранить что угодно: примитивы, объекты, функции. Функция set объединяет (merge) состояние.

import create from 'zustand'

const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}))

export default useStore

Использование хранилища

Хук можно использовать в любом месте приложения (без провайдера!). Компонент будет повторно рендериться (только) при изменении выбранного состояния.

Использование всего хранилища

export default function Counter() {
const { count, increment, decrement, reset } = useStore()

return (
<main>
<h2>{count}</h2>
<div className='btn-box'>
<button onClick={decrement} className='btn decrement'>
-
</button>
<button onClick={increment} className='btn increment'>
+
</button>
<button onClick={reset} className='btn reset'>
0
</button>
</div>
</main>
)
}

В данном случае компонент Counter будет повторно рендериться при любом изменении состояния.

Использование частей состояния (state slices в терминологии Redux)

// хук для "регистрации" повторного рендеринга
function useLogAfterFirstRender(componentName) {
const firstRender = useRef(true)

useEffect(() => {
firstRender.current = false
}, [])

if (!firstRender.current) {
console.log(`${componentName} render`)
}
}

function Count() {
const count = useStore(({ count }) => count)

useLogAfterFirstRender('Count')

return <h2>{count}</h2>
}

function DecrementBtn() {
const decrement = useStore(({ decrement }) => decrement)

useLogAfterFirstRender('Decrement')

return (
<button onClick={decrement} className='btn decrement'>
-
</button>
)
}

function IncrementBtn() {
const increment = useStore(({ increment }) => increment)

useLogAfterFirstRender('Increment')

return (
<button onClick={increment} className='btn increment'>
+
</button>
)
}

function ResetBtn() {
const reset = useStore(({ reset }) => reset)

useLogAfterFirstRender('Reset')

return (
<button onClick={reset} className='btn reset'>
0
</button>
)
}

const Counter = () => (
<main>
<Count />
<div className='btn-box'>
<DecrementBtn />
<IncrementBtn />
<ResetBtn />
</div>
</main>
)

export default Counter

В данном случае будет повторно рендериться только компонент Count и только при изменении значения count.

Рецепты

Если мы перепишем приведенный выше пример следующим образом:

function Count() {
const count = useStore(({ count }) => count)

useLogAfterFirstRender('Count')

return <h2>{count}</h2>
}

function Controls() {
const { decrement, increment, reset } = useStore(
({ decrement, increment, reset }) => ({ decrement, increment, reset })
)

useLogAfterFirstRender('Controls')

return (
<div className='btn-box'>
<button onClick={decrement} className='btn decrement'>
-
</button>
<button onClick={increment} className='btn increment'>
+
</button>
<button onClick={reset} className='btn reset'>
0
</button>
</div>
)
}

const Counter = () => (
<main>
<Count />
<Controls />
</main>
)

export default Counter

То компонент Controls будет рендериться при любом изменении состояния (потому что объекты сравниваются по ссылке, а не по значению).

Для решения этой проблемы предназначена функция shallow, поверхностно сравнивающая объекты для определения их идентичности и, как следствие, необходимости в повторном рендеринге компонента.

import shallow from 'zustand/shallow'

function Controls() {
const { decrement, increment, reset } = useStore(
({ decrement, increment, reset }) => ({ decrement, increment, reset }),
/* 👇 */
shallow
)

useLogAfterFirstRender('Controls')

return (
<div className='btn-box'>
<button onClick={decrement} className='btn decrement'>
-
</button>
<button onClick={increment} className='btn increment'>
+
</button>
<button onClick={reset} className='btn reset'>
0
</button>
</div>
)
}

Пример можно переписать следующим образом:

const useStore = create((set) => ({
count: 0,
controls: {
increment: () => set(({ count }) => ({ count: count + 1 })),
decrement: () => set(({ count }) => ({ count: count - 1 })),
reset: () => set({ count: 0 })
}
}))

function Controls() {
// функция `shallow` больше не нужна
const controls = useStore(({ controls }) => controls)

useLogAfterFirstRender('Controls')

return (
<div className='btn-box'>
<button onClick={controls.decrement} className='btn decrement'>
-
</button>
<button onClick={controls.increment} className='btn increment'>
+
</button>
<button onClick={controls.reset} className='btn reset'>
0
</button>
</div>
)
}

Вместо shallow можно использовать собственную функцию сравнения:

const todos = useStore(
state => state.todos,
(oldTodos, newTodos) => compare(oldTodos, newTodos)
)

Мемоизированные селекторы

Для мемоизации селекторов рекомендуется использовать хук useCallback:

const todoById = useStore(useCallback(state => state.todos[id], [id]))

Если селектор не зависит от области видимости (scope), его можно определить за пределами компонента (это называется фиксированной ссылкой/fixed reference):

const selector = state => state.todos

function TodoList() {
const todos = useStore(selector)

// ...
}

Замена состояния

Для замены состояния вместо объединения можно передать true в качестве второго аргумента функции set:

const useStore = create(set => ({
// ...
clear: () => set({}, true)
}))

Асинхронные операции

Для zustand не имеет значения, какой является операция, синхронной или асинхронной, достаточно просто вызвать set в нужном месте и в нужное время:

const useStore = create((set, get) => ({
todos: [],
loading: false,
error: null,
fetchTodos: async () => {
set({ loading: true })
try {
const response = await fetch(SERVER_URI)
if (!response.ok) throw response
set({ todos: await response.json() })
} catch (e) {
let error = e
// custom error
if (e.status === 400) {
error = await e.json()
}
set({ error })
} finally {
set({ loading: false })
}
}
}))

Чтение состояние в операциях

Функция get позволяет получать доступ к состоянию в любом месте хранилища (за пределами set):

const useStore = create((set, get) => ({
todos: [],
removeTodo(id) {
const newTodos = get().todos.filter(t => t.id !== id)
set({ todos: newTodos })
}
}))

Временные обновления

Функция subscribe позволяет привязаться (bind) к части состояния без запуска повторного рендеринга при изменении этой части. Данную технику рекомендуется использовать в хуке useEffect для выполнения отписки (unsubscribe) при размонтировании компонента:

const useStore = create(set => ({ count: 0, /* ... */ }))

function Counter() {
// получаем начальное состояние
const countRef = useRef(useStore.getState().count)

useEffect(() => {
// подключаемся к хранилищу при монтировании,
// отключаемся при размонтировании
const unsubscribe = useStore.subscribe(
state => (countRef.current = state.count)
)
return () => {
unsubscribe()
}
}, [])
}

Долгосрочное хранение состояния

Функция persist позволяет записывать состояние в любой вид хранилища (по умолчанию используется localStorage):

import create from 'zustand'
import { persist } from 'zustand/middleware'

const useStore = create(persist(
(set, get) => ({
todos: [],
addTodo(newTodo) {
const newTodos = [...get().todos, newTodo]
set({ todos: newTodos })
}
}, {
name: "todos-storage",
getStorage: () => sessionStorage
})
))

Для тех, кто не может жить без Redux

const types = { incrementBy: 'INCREMENT_BY', decrementBy: 'DECREMENT_BY', reset: 'RESET' }

const reducer = (state, { type, payload }) => {
switch (type) {
case types.incrementBy: return { count: state.count + payload }
case types.decrementBy: return { count: state.count - payload }
case types.reset: return { count: 0 }
default: return state
}
}

const useStore = create(set => ({
count: 0,
dispatch: action => set(state => reducer(state, action))
}))

const dispatch = useStore(state => state.dispatch)
dispatch({ type: types.incrementBy, payload: 42 })

С помощью посредника (middleware) redux можно получить еще больше возможностей:

import { redux } from 'zustand/middleware'

const initialState = { count: 0 }

const [useStore, api] = create(redux(reducer, initialState))

const count = useStore(state => state.count)
api.dispatch({ type: types.decrementBy, payload: 24 })

Инструменты разработчика

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

import { devtools } from 'zustand/middleware'

// setState
const useStore = create(devtools(store))
// подробная информация о типе и полезной нагрузке операции
const useStore = create(devtools(redux(reducer, initialState)))

Контекст

Функция createContext предназначена для передачи хука useStore в качестве пропа через контекст. Это может потребоваться для соблюдения паттерна внедрения зависимостей (dependency injection) или для инициализации хранилища с помощью пропов внутри компонента:

import create from 'zustand'
import createContext from 'zustand/context'

const { Provider, useStore } = createContext()

const createStore = () => create(/* ... */)

const App = () => (
<Provider createStore={createStore}>
{/* ... */}
</Provider>
)

const Component = () => {
const state = useStore()
const stateSlice = useStore(selector)

// ...
}

Есть еще несколько менее, на мой взгляд, полезных возможностей, предоставляемых zustand, которые мы рассматривать не будем (обязательно загляните в репозиторий).

Практика

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

yarn create snowpack-app react-zustand --template @snowpack/app-template-react --use-yarn --no-git
# или
# в данном случае флаг `--use-yarn` не нужен
npx create-snowpack-app ...

Нам потребуется некое подобие сервера, от которого мы будем получать начальные тудушки.

Переходим в созданную директорию и устанавливаем json-server:

cd react-zustand
yarn add json-server
# или
npm i json-server

Создаем файл db.json в корневой директории проекта:

{
"todos": [
{
"id": "1",
"text": "Sleep",
"done": true
},
{
"id": "2",
"text": "Eat",
"done": true
},
{
"id": "3",
"text": "Code",
"done": false
},
{
"id": "4",
"text": "Repeat",
"done": false
}
]
}

Определяем в разделе scripts файла package.json команду для запуска сервера:

"server": "json-server -w db.json -d 1000"
  • -w | --watch - файл с данными;
  • -d | --delay - задержка для имитации работы реального сервера.

Запускаем сервер с помощью команды yarn server или npm run server.

По умолчанию сервер запускается по адресу http://localhost:3000/todos.

Структура директории src:

- components
- Loader.jsx - индикатор загрузки
- Error.jsx - обработчик ошибок
- Boundary.jsx - предохранитель
- TodoForm.jsx - форма для создания новой тудушки
- TodoInfo.jsx - статистика
- TodoList.jsx - список тудушек
- TodoItem.jsx - элемент тудушки
- TodoControls.jsx - панель управления
- index.js - повторный экспорт компонентов
- store
- index.js - хранилище
- App.css
- App.jsx
- index.jsx

Создаем хранилище (store/index.js):

import create from 'zustand'

const useStore = create((set, get) => ({
todos: [],
loading: false,
error: null,
info: {},
updateInfo() {
const todos = get().todos
const { length: total } = todos
const active = todos.filter((t) => !t.done).length
const done = total - active
const left = Math.round((active / total) * 100) + '%'
set({ info: { total, active, done, left } })
},
addTodo(newTodo) {
const todos = [...get().todos, newTodo]
set({ todos })
},
updateTodo(id) {
const todos = get().todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
)
set({ todos })
},
removeTodo(id) {
const todos = get().todos.filter((t) => t.id !== id)
set({ todos })
},
completeActiveTodos() {
const todos = get().todos.map((t) => (t.done ? t : { ...t, done: true }))
set({ todos })
},
removeCompletedTodos() {
const todos = get().todos.filter((t) => !t.done)
set({ todos })
},
async fetchTodos() {
set({ loading: true })
try {
const response = await fetch(SERVER_URI)
if (!response.ok) throw response
set({ todos: await response.json() })
} catch (e) {
let error = e
// custom error
if (e.statusCode === 400) {
error = await e.json()
}
set({ error })
} finally {
set({ loading: false })
}
}
}))

export default useStore

У нас имеется состояние для тудушек, загрузки, ошибки и статистики, несколько стандартных и 2 дополнительных синхронных операции, а также 1 асинхронная операция - получение задач от сервера.

Основной файл приложения (App.jsx):

import './App.css'
import React from 'react'
// хранилище
import useStore from './store'
// компоненты
import {
Boundary,
TodoControls,
TodoForm,
TodoInfo,
TodoList
} from './components'

// одна из фишек, которые мы не рассматривали
// вызываем асинхронную операцию для получения тудушек от сервера за пределами компонента
// если сервер отвечает достаточно быстро
// мы получаем начальное состояние до рендеринга компонентов
useStore.getState().fetchTodos()

const App = () => (
<>
<header>
<h1>Zustand Todo App</h1>
</header>
<main>
<Boundary>
<TodoForm />
<TodoInfo />
<TodoControls />
<TodoList />
</Boundary>
</main>
<footer>
<p>&copy; Not all rights reserved.<br />
Sad, but true</p>
</footer>
</>
)

export default App

Форма для создания новой тудушки (components/TodoForm.jsx):

import React, { useEffect, useState } from 'react'
// утилита для генерации идентификаторов
// yarn add nanoid or
// npm i nanoid
import { nanoid } from 'nanoid'
// хранилище
import useStore from '../store'

export const TodoForm = () => {
const [text, setText] = useState('')
const [submitDisabled, setSubmitDisabled] = useState(true)
/* 👇 */
const addTodo = useStore(({ addTodo }) => addTodo)

useEffect(() => {
setSubmitDisabled(!text.trim())
}, [text])

const onChange = ({ target: { value } }) => {
setText(value)
}

const onSubmit = (e) => {
e.preventDefault()
if (submitDisabled) return
const newTodo = {
id: nanoid(),
text,
done: false
}
/* 👇 */
addTodo(newTodo)
setText('')
}

return (
<form className='todo-form' onSubmit={onSubmit}>
<label htmlFor='text'>New todo text</label>
<div>
<input
type='text'
required
value={text}
onChange={onChange}
style={
!submitDisabled ? { borderBottom: '2px solid var(--success)' } : {}
}
/>
<button className='btn-add' disabled={submitDisabled}>
Add
</button>
</div>
</form>
)
}

Список тудушек (components/TodoList.jsx):

import React, { useLayoutEffect, useRef } from 'react'
// библиотека для анимации
// yarn add gsap or
// npm i gsap
import { gsap } from 'gsap'
// хранилище
import useStore from '../store'
import { TodoItem } from './TodoItem'

export const TodoList = () => {
/* 👇 */
const todos = useStore(({ todos }) => todos)
const todoListRef = useRef()
const q = gsap.utils.selector(todoListRef)

useLayoutEffect(() => {
if (todoListRef.current) {
gsap.fromTo(
q('.todo-item'),
{
x: 100,
opacity: 0
},
{
x: 0,
opacity: 1,
stagger: 1 / todos.length
}
)
}
}, [])

/* 👇 */
return (
todos.length > 0 && (
<ul className='todo-list' ref={todoListRef}>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
)
)
}

Элемент тудушки (components/TodoItem.jsx):

import React from 'react'
import { gsap } from 'gsap'
// утилита для сравнения объектов
import shallow from 'zustand/shallow'
// хранилище
import useStore from '../store'

export const TodoItem = ({ todo }) => {
/* 👇 */
const { updateTodo, removeTodo } = useStore(
({ updateTodo, removeTodo }) => ({
updateTodo,
removeTodo
}),
shallow
)

const remove = (id, target) => {
gsap.to(target, {
opacity: 0,
x: -100,
// удаляем тудушку после завершения анимации
onComplete() {
/* 👇 */
removeTodo(id)
}
})
}

const { id, text, done } = todo

return (
<li className='todo-item'>
<input type='checkbox' onChange={() => {
/* 👇 */
updateTodo(id)
}} checked={done} />
<span
style={done ? { textDecoration: 'line-through' } : {}}
className='todo-text'
>
{text}
</span>
<button
className='btn-remove'
onClick={(e) => {
/* 👇 */
remove(id, e.target.parentElement)
}}
>

</button>
</li>
)
}

Панель управления (components/TodoControls.jsx):

import React from 'react'
// утилита для сравнения объектов
import shallow from 'zustand/shallow'
// хранилище
import useStore from '../store'

export const TodoControls = () => {
/* 👇 */
const { todos, completeActiveTodos, removeCompletedTodos } =
useStore(
({ todos, completeActiveTodos, removeCompletedTodos }) => ({
todos,
completeActiveTodos,
removeCompletedTodos
}),
shallow
)

if (!todos.length) return null

return (
<div className='todo-controls'>
<button className='btn-complete' onClick={completeActiveTodos}>
Complete all todos
</button>
<button className='btn-remove' onClick={removeCompletedTodos}>
Remove completed todos
</button>
</div>
)
}

Статистика (components/TodoInfo.jsx):

import React, { useEffect } from 'react'
// утилита для сравнения объектов
import shallow from 'zustand/shallow'
// хранилище
import useStore from '../store'

export const TodoInfo = () => {
/* 👇 */
const { todos, info, updateInfo } = useStore(
({ todos, info, updateInfo }) => ({ todos, info, updateInfo }),
shallow
)

// обновляем статистику при каждом изменении тудушек
useEffect(() => {
/* 👇 */
updateInfo()
}, [todos])

if (!info || !todos.length) return null

return (
<div className='todo-info'>
{['Total', 'Active', 'Done', 'Left'].map((k) => (
<span key={k}>
{k}: {info[k.toLowerCase()]}
</span>
))}
</div>
)
}

Наконец, предохранитель:

import React from 'react'
// утилита для сравнения объектов
import shallow from 'zustand/shallow'
// хранилище
import useStore from '../store'
// компоненты
import { Error } from './Error'
import { Loader } from './Loader'

export const Boundary = ({ children }) => {
/* 👇 */
const { loading, error } = useStore(
({ loading, error }) => ({ loading, error }),
shallow
)

/* 👇 */
// yarn add react-loader-spinner
if (loading) return <Loader width={50} />

/* 👇 */
if (error) return <Error error={error} />

return <>{children}</>
}

Запускаем сервер с помощью команды yarn start или npm start и тестируем приложение.




Как видим, все работает, как ожидается.

Что насчет производительности - спросите вы. Давайте посмотрим.

Редактируем файл components/TodoControls.jsx следующим образом:

// ...
import { nanoid } from 'nanoid'

export const TodoControls = () => {
const {
// ...
addTodo,
updateTodo
} = useStore(
({
// ...
addTodo,
updateTodo
}) => ({
// ...
addTodo,
updateTodo
}),
shallow
)

// функция для создания 2500 тудушек
const createManyTodos = () => {
const times = []
for (let i = 0; i < 25; i++) {
const start = performance.now()
for (let j = 0; j < 100; j++) {
const id = nanoid()
const todo = {
id,
text: `Todo ${id}`,
done: false
}
addTodo(todo)
}
const difference = performance.now() - start
times.push(difference)
}
const time = Math.round(times.reduce((a, c) => (a += c), 0) / 25)
console.log('Create time:', time)
}

// функция для обновления всех тудушек
const updateAllTodos = () => {
const todos = useStore.getState().todos
const start = performance.now()
for (let i = 0; i < todos.length; i++) {
updateTodo(todos[i].id)
}
const time = Math.round(performance.now() - start)
console.log('Update time:', time)
}

// if (!todos.length) return null

return (
<div className='todo-controls'>
{/* ... */}
<button className='btn-create' onClick={createManyTodos}>
Create 2500 todos
</button>
<button className='btn-update' onClick={updateAllTodos}>
Update all todos
</button>
</div>
)
}

Отключаем получение задач от сервера в App.jsx:

// useStore.getState().fetchTodos()

Нажимаем на кнопку Create 2500 todos:


Время создания 2500 тудушек составляет 6-7 мс.

Нажимаем Update all todos:


Время обновления 2500 тудушек составляет 1100-1200 мс.

Данные показатели очень близки к показателям "чистого" React - при использовании в качестве хранилища состояния связки useContext/useReducer, и намного превосходят показатели Redux в лице Redux Toolkit.