React: Zustand State Manager

от автора

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

На днях, бороздя просторы Сети в поисках вдохновения, наткнулся на 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!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/646339/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *