Эта статья — перевод оригинальной статьи Madushika Perera "Zustand’s Guide to Simple State Management"

Также я веду телеграм канал “Frontend по-флотски”, где рассказываю про интересные вещи из мира разработки интерфейсов.

Вступление

За последние несколько лет управление состоянием в React претерпело значительные изменения. Многие перешли с Flux на Redux и ищут еще более простые решения. Кроме того, появление React Hooks открыло новые возможности для управления состоянием.

Некоторые из новых библиотек управления состоянием, которые появились на горизонте, — это Recoil, Jotai, Radio Active state и Zustand.

В этой статье я расскажу о Zustand, который предоставляет легкий и простой способ управления состоянием в React.

Что такое Zustand?

Zustand — это библиотека управления состоянием с открытым исходным кодом, разработанная создателями Jotai и React-spring (лучшая библиотека анимации React). Он уже набрал более чем 50 000 еженедельных загрузок.

На данный момент это одна из самых легких библиотек управления состоянием, ее размер составляет 1,5 КБ. Несмотря на то, что она легкая, она решает некоторые важные проблемы, такие как:

Чем он отличается от остальных?

Вы можете задаться вопросом, почему те же создатели Jotai создали Zustand? Это два противоположных подхода. Jotai стремится предоставить простое решение для useState и useContext, создавая атомарное состояние (точно так же, как Recoil).

Zustand использует внешнее хранилище и предоставляет несколько хуков для его подключения.

Тогда, разве это не больше похоже на Redux? Если мы сравним Zustand с Redux, одно из основных отличий — это требуемый шаблонный код. Zustand требует меньше из-за своего самоуверенного подхода. Кроме того, сочетание следующих функций делает его уникальным;

  • Он не оборачивает приложение в провайдеров контекста.

  • Он позволяет добавлять функции подписчика, чтобы компоненты могли привязываться к состоянию без повторного рендеринга при мимолётных обновлениях.

  • Использует мемоизированные селекторы, а функция React useCallback API позволяет оптимизировать производительность в параллельном режиме.

  • Он довольно хорошо поддерживает middlewar. Вы можете подключить его к таким библиотекам, как Immer, чтобы сократить код в редьюсерах и внести изменения во вложенное состояние.

Начинаем с React и Zustand

Давайте посмотрим, как Zustand работает с React. Чтобы продемонстрировать его возможности, я создам небольшое приложение Pokemon. Это приложение состоит из хранилища, компонента списка и формы ввода, которая обрабатывает ввод пользователя.

Шаг 1. Установка

Установка Zustand так же проста, как установка любого из пакетов npm. Давайте установим его с помощью npm или yarn.

npm install zustand
yarn add zustand

Шаг 2. Создаём хранилище

Далее давайте создадим хранилище, как и в любой другой библиотеке управления состоянием.

import create from "zustand";
const useStore = create((set) => ({
pokemons: [{ id: 1, name: "Bulbasaur" },
 { id: 2, name: "Ivysaur" },
 { id: 3, name: "Venusaur" },
 { id: 4, name: "Charmander" },
 { id: 5, name: "Charmeleon" },
],
addPokemons: (pokemon) =>
set((state) => ({
 pokemons: [
 { name: pokemon.name, id: Math.random() * 100 },
  ...state.pokemons,
 ]})),
removePokemon: (id) =>
 set((state) => ({
   pokemons: state.pokemons.filter((pokemon) => pokemon.id !== id),
 })),
}));
export default useStore;

Хранилище создается с помощью create API, и экшены могут быть созданы, как показано выше. Доступ к этому хранилищу можно получить через приложение без использования какого-либо провайдера HOC (компонента более высокого порядка), как в Redux.

Шаг 3. Получаем доступ к хранилищу

Zustand не полностью привязан к React. Его можно использовать с любыми другими библиотеками, такими как Vue или Angular. Давайте посмотрим, как мы можем получить доступ к хранилищу из компонента.

Доступ к состоянию хранилища и отображение данных в виде списка

import useStore from "../store";
function List() {
 const pokemons = useStore((state) => state.pokemons);
 const removePokemon = useStore((state) => state.removePokemon);
return (
 <div className="row">
  <div className="col-md-4"></div>
  <div className="col-md-4">
  <ul>{pokemons.map((pokemon) => (
     <li key={pokemon.id}>
      <div className="row">
      <div className="col-md-6">{pokemon.name} </div>
      <div className="col-md-6">
        <button className="btn btn-outline-secondary btn-sm"
         onClick={(e) => removePokemon(pokemon.id)}>X
        </button>
       </div>
      </div>
     </li>
    ))}
  </ul>
</div>
 <div className="col-md-4"></div>
</div>);
}
export default List;

Обновляем состояния с помощью формы

import { useState } from "react";
import useStore from "../store";

function Form() {
  const [name, setName] = useState("");
  const addPokemons = useStore((state) => state.addPokemons);

  const onChange = (e) => {
    setName(e.target.value);
  };

  const addPokemon = () => {
    addPokemons({ name: name });
    clear();
  };

  const clear = () => setName("");

  return (
    <div className="row">
      <div className="col-md-2"></div>
      <div className="col-md-6">
        <input
          type="text"
          className="form-control"
          onChange={onChange}
          value={name}
        ></input>
      </div>
      <div className="col-md-2">
        <button
          className="btn btn-outline-primary"
          onClick={(e) => addPokemon()}
        >
          Add
        </button>
      </div>
      <div className="col-md-2"></div>
    </div>
  );
}

export default Form;

Как видите, Zustand намного точнее и проще, чем Redux, Recoil или Jotai.

Шаг 4. Обработка асинхронных экшенов

Одним из реальных применений действия Zustand является получение данных через REST API. Эти экшены мы должны выполнять асинхронно.

import axios from "axios";
const useStore = create(set => ({
  pokemons: [],
  getPokemons: async ()=> {
    const response = await axios.get('')
    set({ pokemons: response.data })
  }
}))

Асинхронный экшен можно обработать, используя async/await в JavaScript. В приведенном выше примере вы можете увидеть, как асинхронные экшены запускаются через axios.

Шаг 5: Middleware для Zustand

Zustand имеет несколько уникальных middleware'ов из коробки. Наиболее известными middleware'ами являются dev tools (использующие инструменты разработки Redux) и Persist, которые могут сохранять данные в браузере.

let useStore : (set)=>{
  /* state and actions */
};
useStore = persist(useStore, { name: 'Bulbasaur' })
useStore = devtools(useStore)

export default useStore = create(useStore);

С Zustand вы также можете создать собственный middleware и плагин.

Заключение

В заключение, библиотека Zustand — одна из самых легких библиотек управления состоянием. Он также обеспечивает гибкость для расширения своей функциональности с помощью подключаемых модулей и middlerware для улучшения опыта разработки.

Например, Zustand позволяет получить доступ к хранилищу вне React и совместим с Redux DevTools, Persist и поддерживает React Hooks, TypeScript и т. д.

Таким образом, Zustand выглядит отличным кандидатом на пост управления состоянием в приложении. Простота библиотеки также делает ее хорошим вариантом для начинающих.

Наконец, спасибо, что нашли время, чтобы прочитать это. Я хотел бы видеть ваши вопросы и комментарии ниже.

Ваше здоровье!

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


  1. kellas
    18.04.2022 19:17
    +4

    Еще когда только набирал популярность redux я удивлялся ради чего весь этот огород городить, и с тех пор появляются все новые и новые стейт менеджеры... как будто новички их придумывают даже не разобравшись с тем что браузер им дает из коробки.

    Открою всем читателям "революционный" нативный стейт менеджер - indexedDB.
    - один источник истины для всех вкладок с сайтом
    - доступ к хранилищу из web/shared/service worker
    - оффлайн режим
    - индексация и моментальный поиск
    - десятки тысяч записей без тормозов
    - язык запросов(а-ля селекторы) почти как у mongo
    - реактивность(при определенном подходе)
    - вы можете создавать для одного домена много баз(сторов)
    - инструмент отладки стора уже встроен в твой браузер(см. Application->Storage->IndexedDB )
    - дружит с любыми фреймворками
    - иммутабельней сотен троеточий!

    Посмотрите сами https://dexie.org/

    трямс - и стор готов

    const db = new Dexie('MyDatabase');
    db.version(1).stores({
    	friends: '++id, name, age, avatar',
      keyval: 'key,value'
    });
    

    подписываемся на обновления коллеции

    import { useLiveQuery } from "dexie-react-hooks";
    import { db } from "./db";
      export function FriendList () {
        
        const friends = useLiveQuery(() => db.friends.where("age").between(18, 65).toArray(););
        
        return <>
            {friends?.map(friend =><div key={friend.id}>{friend.name}, {friend.age}<
           </div>)}
        </>;
      }


    Ну и "экшон" если говорить понятиями редакса. add / put / bulkPut

    await db.friends.add({
    		name: 'Camilla',
    		age: 25,
    		avatar: await getBlob('camilla.png')
    });
    // ну или 
    DB.friends.bulkPut( await (await fetch('/api/friends/')).json() )
    Пример простого key value
    
    const db = new Dexie(config.db.name)
    db.version(config.db.version).stores({
      keyval   : 'key, value',
    })
    
    DB.keyval.put({ key:'currentChainId', value: ethApp.chainId })
    

    где-то в воркере обновляем баланс при изменении id текущего пользователя

    liveQuery(() => DB.keyval.get('currentAccount')).subscribe({
      next: () => {
        updateUserBalances()
      }
    })


    1. napa3um
      18.04.2022 21:43
      +1

      Это разные весовые категории. И, собственно, по весу (~100 kB vs ~2 kB), и по смыслу (персистентное хранилище vs состояние вида), и, соответственно, по памяти/процессору. А так да, тоже недавно открыл для себя Dixie, впечатлился и в восторге :).


      1. kellas
        19.04.2022 12:52

        IndexedDB - не либа и ни сколько не весит. Я привел в пример dexie как удобную обертку для IndexedDB. Есть другие способы работать с ней - https://habr.com/ru/post/569376/ и другие(наверняка более легковесные) обертки - https://github.com/jakearchibald/idb

        Я вообще к тому, что все это уже есть давно нативно, еще до редакса было. Я понимаю еще какие-то движения в сторону "лучшей" реактивности, типа rxjs. Но зачем вот это простое хранение в памяти? Ну пишите сразу в localStorage и из него читайте или любой singleton класс можно сделать с геттерами и сеттерами, да хоть в window.DATA создайте объект и все туда пишите и тоже не нужно оборачивать ничего в провайдер контекста )))


        1. napa3um
          19.04.2022 13:23

          Ну можно сразу в удалённую базу данных писать состояние какого-нибудь слайдера из GUI, зачем останавливаться на полумерах :). Вот ваш вариант с синглтоном - вы не можете помыслить его без IndexedDB, что ли? Вы намешали кучу проблем и кучу методов для их решения, перемешали все архитектурные слои :). Вы правильно вспомнили rxJS - это именно тот архитектурный слой, который вы, кажется, не можете рассмотреть, когда он вырождается до простого сеттера-геттера, и хотите к нему прикрутить в нагрузку "хоть что-то полезное" :). Не надо.


  1. markelov69
    19.04.2022 12:15

    Хах, классика, идет сравнение с убогими "соперниками" в виде Redux и Recoil, но ни слова про MobX, который на 100 голов выше всей этой шушеры, разумеется включая Zustand. Ибо стейт менеджер не основанный на Getters/Setters (появились в JS в 2010 году) это просто нелепо и смешно.


  1. Delphin92
    20.04.2022 13:27

    Идея понятная и, в принципе, на том же Redux можно получить схожий синтаксис. Но как быть с масштабированием?

    В реальном проекте в сторе будет лежать куча малосвязанных друг с другом данных и куча функций для их изменения. Чтобы не было нечитаемого и неподдерживаемого спагетти, надо как минимум разнести по разным файлам, а желательно еще и как-то запретить изменения "чужой" части стейта.

    Как предлагается решать данную проблему?


    1. markelov69
      20.04.2022 16:26

      Писать такой же говнокод, который обычно пишут абсолютно все любители redux, redux-thunk и прочей ереси. Ничего нового.


    1. napa3um
      21.04.2022 03:10

      А в чём проблема нарезать стор/слайсы по модулям так, как вам удобнее? Библиотека этому никак не мешает, джаваскрипт у вас не отбирает. Или вы критикуете сам архитектурный паттерн с централизованным состоянием приложения? Да, он - не серебряная пуля, иногда бывает полезным, иногда лишним, зависит от вашего проекта. Иногда хватит дерева из визуальных компонентов с локальными состояниями (и независимыми походами в API), иногда потребуется централизованный стор для состояния приложения, а иногда помимо централизации потребуется и сложная оркестрация частей этого стора (см. mobX, rxJS, redux-saga). Выбирайте инструменты по задаче, а не по хайпу (культу карго) :).


      1. Delphin92
        21.04.2022 11:34

        Я критикую отсутствие инкапсуляции. В данной библиотеке кто угодно может изменить что угодно, что даже отражено в документации. Я считаю это большим источником проблем.

        Посмотрим на код из примера:

        const createBearSlice: StoreSlice<IBearSlice, IFishSlice> = (set, get) => ({
          eatFish: () =>
            set((prev) => ({ fishes: prev.fishes > 1 ? prev.fishes - 1 : 0 })),
          sayHello: () => {
            console.log(`hello ${get().fishes} fishes`);
          },
        });
        
        const createFishSlice: StoreSlice<IFishSlice> = (set, get) => ({
          fishes: 10,
        });

        Представьте что у нас таких едоков пол проекта. А потом мы решили добавить рядом с fishes еще поле. Получается, нам надо найти все места, где стейт меняется и актуализировать.

        Подход "всегда спредить стейт" или использование immerJS частично решит проблему, но не полностью, так как изменения могут сопровождаться побочными эффектами, которые тоже надо будет везде учитывать.

        В общем, это все напоминает древнее процедурное программирование. Есть общий набор глобальных переменных, есть процедуры, которые как-то их меняют. Все остальное исключительно на совести программистов. От чего уходили, к тому и вернулись :)


        1. napa3um
          21.04.2022 11:38

          Всё ещё не понимаю вашу проблему. Изолируйте логику так, как вам нужно, сами, библиотека не мешает вам. Но и не помогает ванговать вам ахритектуру вашего приложения (структуру вашего стора). Хотите - разделяйте, хотите - объединяйте. Не смог вас понять, видимо.

          Если бизнес-логика изменилась - очевидно, придётся поменять все зависимые от неё части кода. Как иначе-то?

          От чего уходили, к тому и вернулись

          Возможно, вы этой библиотекой пытаетесь уйти от какой-то проблемы, к которой она не имеет отношения? :)


          1. Delphin92
            21.04.2022 13:12

            Как иначе-то?

            Уменьшать число зависимостей, в том числе явно запрещая изменять "чужие" данные напрямую, а только через интерфейс. Т.е. я ожидаю увидеть что-то типа такого:

            const createBearSlice: StoreSlice<IBearSlice, IFishSlice> = (set, get) => ({
              bearFull: false,
              eatFish: () => {
                // fishes недоступно и менять нельзя, можно только вызвать метод:
                fishSlice.fishDie();
                // а свои поля менять можно:
                set((prev) => ({ bearFull: true }));
              },
              sayHello: () => {
                // получать "чужие" поля можно, но указав источник:
                console.log(`hello ${get().fishSlice.fishes} fishes`);
              },
            });
            
            const createFishSlice: StoreSlice<IFishSlice> = (set, get) => ({
              fishes: 10,
              // интерфейсный метод для снижения поголовья рыб:
              fishDie: () =>
                set((prev) => ({ fishes: prev.fishes > 1 ? prev.fishes - 1 : 0 })),
            });


            1. napa3um
              21.04.2022 13:38

              И кто вам мешает так сделать? Оверинжинирте свои типы и инкапсуляции как хотите, библиотека этому никак не мешает :).