
Привет, друзья!
На днях, бороздя просторы Сети в поисках вдохновения, наткнулся на Zustand, инструмент для управления состоянием React-приложений, наиболее полно (среди более чем многочисленных альтернатив) отвечающий моим представлениям о, если не идеальном, то адекватном современному React state manager (см., например, эту статью).
Рассказу о нем и будет посвящена данная статья. Сначала немного теории, затем немного практики — по традиции, "запилим" простую "тудушку".
Если вам это интересно, прошу под кат.
Теория
Установка
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>© 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 (см. эту статью).
Таким образом, zustand определенно заслуживает нашего внимания. На мой взгляд, на сегодняшний день это лучший из инструментов для управления состоянием современных React-приложений.
Пожалуй, это все, чем я хотел поделиться с вами в данной статье.
Благодарю за внимание и happy coding!

