Привет, друзья!


На днях, бороздя просторы Сети в поисках вдохновения, наткнулся на 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>&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 (см. эту статью).


Таким образом, zustand определенно заслуживает нашего внимания. На мой взгляд, на сегодняшний день это лучший из инструментов для управления состоянием современных React-приложений.


Пожалуй, это все, чем я хотел поделиться с вами в данной статье.


Благодарю за внимание и happy coding!




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


  1. nin-jin
    19.01.2022 10:31
    +7

    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 class Counter {
        count = 0
        increment() { this.count ++ }
        decrement() { this.count -- }
        reset() { this.count = 0 }
    }


  1. markelov69
    19.01.2022 15:06
    -1

    На мой взгляд, на сегодняшний день это лучший из инструментов для управления состоянием современных React-приложений

    Что???? Рен-ТВ'шники перебрались на хабр и высказывают свои абсурдные взгляды теперь и тут.


  1. bayarsaikhan
    19.01.2022 16:51

    В принципе прямо сейчас можно нагуглить с как минимум десяток статей о "наверное лучших на данный момент менеджеров стейта для React". Все это создает невероятный когнитивный лоад на расшатанную фронтендерскую психику. Где-то в бекграунде назойливый вопрос - а что не так с встроенным стейт менеджментом Реакта?


  1. Alexandroppolus
    19.01.2022 17:27

    Локальные сторы, хуки для которых создаются через create, - по сути тот же useState.

    Сторы с контекстом совершенно аналогично можно нарисовать и на Редуксе.

    В общем, чего-то принципиального нового в сабже я не заметил.


  1. JustDont
    19.01.2022 18:21

    О, к нам в гости заглянул кто-то из 2019 года!
    Как там у вас в прошлом, без коронавируса?


    Заодно быстро ввожу в курс дела: в 2020 году надо будет топить за Recoil, Zustand будет уже всё.
    Увы, не подскажу, за что надо топить в 2021, но не сомневаюсь, что что-то нескучное изобрели и там.


    1. nin-jin
      19.01.2022 20:43

      Effector во всех конференциях страны в том году.


  1. IvanIvanc
    21.01.2022 16:49

    Ещё пару лет и будет mobx, но зачем?