Skip to content

Latest commit

 

History

History
1034 lines (847 loc) · 27.3 KB

zustand.md

File metadata and controls

1034 lines (847 loc) · 27.3 KB
sidebar_position title description keywords tags
16
Шпаргалка по Zustand
Шпаргалка по Zustand
javascript
js
react.js
reactjs
react
state manager
state management
state
zustand
cheatsheet
шпаргалка
инструмент управления состоянием
управление состоянием
состояние
javascript
js
react.js
reactjs
react
state manager
state management
state
zustand
cheatsheet
шпаргалка
инструмент управления состоянием
управление состоянием
состояние

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.