К списку уроков

Урок 15. Сторы: Zustand и селекторы

Онлайн и в Б-734, 16 ноября (понедельник), 15:00

Предисловие

Для вашего удобства текстовый курс перенесен на отдельную платформу. Ссылка для поступления: тык

Мы настоятельно рекомендуем пользоваться именно этой платформой по ходу курса. Ссылка на текущее занятие: тык

⚠️Прежде чем переходить к заданиям - обязательно зарегистрируйтесь на курс. Иначе вас закинет на общий поток и вы не сможете взаимодействовать с сверстниками во втором модуле курса. Это не смертельно и вполне решаемо, но тем не менее.

Сторы: Zustand и селекторы

Данные, которые нужны многим страницам

Посмотрите, где сейчас используются карточки в ITAM Board:

  • доска загружает карточки и показывает их в колонках;

  • модальное окно ищет открытую карточку и обновляет её счёт после голосования;

  • страница редактирования находит карточку по id из адреса и сохраняет изменения;

  • страница пользователя показывает карточки автора;

  • форма создания добавляет новую карточку;

  • комментарии меняют число комментариев на превью.

Если карточки хранятся в useState на доске, остальным страницам приходится получать их через пропсы или загружать заново. Возникают проблемы:

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

  • при переходе с доски на страницу пользователя карточки загружаются ещё раз;

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

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

Похожая ситуация с текущим пользователем: он нужен шапке, странице профиля и форме профиля. После сохранения профиля новое имя должно сразу появиться в шапке.

Почитать ещё

Почему не контекст

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

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

Логика живёт в компоненте. Функции loadCards, createCard и vote пришлось бы писать внутри провайдера. Провайдер — это компонент со своими рендерами и эффектами, и функции в нём нужно оборачивать в useCallback, чтобы значение контекста не менялось при каждом рендере.

Данные недоступны вне React. Прочитать или изменить значение контекста можно только из компонента. Из обычной функции, например из кода, который выполняется после ответа сервера вне компонента, до контекста не добраться.

Контекст хорошо подходит для значений, которые меняются редко: тема оформления, язык интерфейса, настройки раздела. Для данных приложения, которые часто меняются и нужны многим компонентам, используют сторы.

Почитать ещё

  • React: useContext

Что такое стор

Стор — объект вне дерева компонентов, в котором хранится состояние и функции для его изменения. Функции изменения называют действиями.

компоненты  ──подписка на часть──►  стор  ◄──  действия
                                    cards      loadCards()
                                    status     createCard()
                                    filters    vote()

Компоненты подписываются на стор и указывают, какая часть состояния им нужна. Когда состояние меняется, перерисовываются только те компоненты, у которых изменилась нужная им часть. Компоненты меняют состояние, вызывая действия стора.

Это та же схема «состояние и рендер» с пятого занятия. Состояние хранится в одном месте и меняется только действиями, а интерфейс строится из состояния. Разница в том, что состояние вынесено из компонентов, а перерисовкой занимается React.

Стор не заменяет useState. В сторе хранят данные, которые нужны нескольким частям приложения или должны пережить переход между страницами. Состояние, которое нужно одному компоненту, остаётся в нём: открыто ли модальное окно, что введено в поле комментария, отправляется ли форма.

Почитать ещё

Библиотеки для сторов

Для React написано много библиотек сторов. Самые распространённые:

  • Redux и Redux Toolkit — одна из первых и самая известная библиотека. Состояние меняется только через действия и функции-обработчики, у библиотеки строгие правила и хорошие инструменты отладки. Встречается во многих существующих проектах.

  • MobX — состояние хранится в наблюдаемых объектах. Изменение поля объекта автоматически обновляет компоненты, которые его используют.

  • Zustand — небольшая библиотека с простым API. Стор создаётся одной функцией, состояние — обычный объект, действия — обычные функции. Хорошо работает с TypeScript.

  • Jotai — состояние хранится в маленьких независимых частях, которые компоненты объединяют между собой.

В ITAM Board мы используем Zustand. Стор карточек и стор пользователя занимают два коротких файла, которые легко прочитать целиком.

Идеи, на которых построены сторы, общие для всех библиотек: состояние вне компонентов, подписка на часть состояния, действия для изменений. Поэтому, если в команде используют Redux или другую библиотеку, разобраться в ней после Zustand будет несложно.

Почитать ещё

Первый стор на Zustand

Установите библиотеку:

npm install zustand

Стор создаётся функцией create:

import { create } from 'zustand';

type CounterState = {
  count: number;
  increment: () => void;
  reset: () => void;
};

export const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}));
  • create получает функцию, которая возвращает начальный объект стора: состояние и действия вместе.

  • Функция получает аргумент set, которым действия меняют состояние.

  • Результат create — хук. По соглашению его называют use...Store.

Компоненты читают стор этим хуком:

export function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return (
    <button type="button" onClick={increment}>
      Нажато {count} раз
    </button>
  );
}

export function ResetButton() {
  const reset = useCounterStore((state) => state.reset);
  return <button type="button" onClick={reset}>Сбросить</button>;
}

Функция, которую передают в хук, называется селектором: она выбирает из стора нужную часть. Компоненты Counter и ResetButton могут находиться в разных частях приложения, и провайдер им не нужен. Когда ResetButton вызывает reset, Counter перерисуется с новым значением.

Запись create<CounterState>()(...) содержит два вызова подряд. Первый вызов принимает тип состояния, второй — функцию стора. Такая форма нужна, чтобы TypeScript правильно вывел типы, и в документации Zustand для TypeScript используют именно её.

Почитать ещё

Изменение состояния: set

Функция set меняет состояние стора. У неё две формы.

Объект с изменениями. set сливает переданный объект с текущим состоянием: указанные поля получают новые значения, остальные остаются прежними.

set({ status: 'loading', error: null });

После этого вызова status и error изменятся, а cards, filters и действия останутся на месте.

Функция от текущего состояния. Если новое значение зависит от предыдущего, в set передают функцию, которая получает текущее состояние и возвращает изменения:

set((state) => ({ cards: [card, ...state.cards] }));

Это похоже на запись setCards((prev) => ...) из useState.

Слияние у set поверхностное: объединяются только поля верхнего уровня. Вложенные объекты заменяются целиком, поэтому их обновляют через spread:

// Неправильно: filters заменятся объектом из одного поля search
set({ filters: { search: 'хакатон' } });

// Правильно: остальные поля фильтров сохраняются
set((state) => ({ filters: { ...state.filters, search: 'хакатон' } }));

Состояние в сторе обновляют иммутабельно, как в useState. Zustand сравнивает результат селектора со старым по ссылке, и если изменить массив на месте через push, компоненты не узнают об изменении.

// Неправильно: массив тот же, компоненты не перерисуются
set((state) => {
  state.cards.push(card);
  return { cards: state.cards };
});

// Правильно: новый массив
set((state) => ({ cards: [card, ...state.cards] }));

Почитать ещё

Асинхронные действия

Действие стора может быть асинхронной функцией: отправить запрос, дождаться ответа и записать результат в состояние.

import { create } from 'zustand';
import { getErrorMessage, type Card } from '@/shared/api';
import * as cardsApi from '../api/cards-api';

type CardsState = {
  cards: Card[];
  status: 'idle' | 'loading' | 'ready' | 'error';
  error: string | null;
  loadCards: () => Promise<void>;
};

export const useCardsStore = create<CardsState>()((set) => ({
  cards: [],
  status: 'idle',
  error: null,

  loadCards: async () => {
    set({ status: 'loading', error: null });
    try {
      const cards = await cardsApi.getCards();
      set({ cards, status: 'ready' });
    } catch (err) {
      set({ status: 'error', error: getErrorMessage(err) });
    }
  },
}));
  • Статусы загрузки хранятся в сторе вместе с данными. Любой компонент может узнать, идёт ли загрузка.

  • Запросы выполняют функции из сегмента api. Стор знает, какую функцию вызвать, но не знает, как устроен запрос.

  • Запись import * as cardsApi импортирует все экспорты модуля как один объект. Так в коде стора сразу видно, что cardsApi.getCards — запрос, а не действие стора с похожим именем.

Страница вызывает действие в эффекте:

export function BoardPage() {
  const cards = useCardsStore((state) => state.cards);
  const status = useCardsStore((state) => state.status);
  const error = useCardsStore((state) => state.error);
  const loadCards = useCardsStore((state) => state.loadCards);

  useEffect(() => {
    loadCards();
  }, [loadCards]);

  if (status === 'error') {
    return <ErrorMessage message={error ?? 'Не удалось загрузить карточки'} onRetry={loadCards} />;
  }
  // ...
}

Действия стора — одни и те же функции на всё время работы приложения. Поэтому loadCards можно указывать в зависимостях эффекта, и эффект не будет перезапускаться. Кнопка «Повторить» просто вызывает то же действие.

Отменять запрос, как в эффекте двенадцатого занятия, здесь не нужно: результат записывается в стор, а не в состояние компонента, и компонент, который исчез с экрана, не получит устаревших данных.

Почитать ещё

Стор карточек

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

// src/entities/card/model/cards-store.ts
import { create } from 'zustand';
import { getErrorMessage, type Card, type CardCreate, type CardUpdate, type VoteValue } from '@/shared/api';
import * as cardsApi from '../api/cards-api';
import { DEFAULT_FILTERS, type CardFilters } from './filter-cards';

type CardsState = {
  cards: Card[];
  status: 'idle' | 'loading' | 'ready' | 'error';
  error: string | null;
  filters: CardFilters;

  loadCards: () => Promise<void>;
  createCard: (body: CardCreate) => Promise<Card>;
  updateCard: (cardId: string, changes: CardUpdate) => Promise<Card>;
  deleteCard: (cardId: string) => Promise<void>;
  vote: (cardId: string, value: VoteValue | null) => Promise<Card>;
  changeCommentsCount: (cardId: string, delta: number) => void;
  setFilters: (changes: Partial<CardFilters>) => void;
};

function replaceCard(cards: Card[], updated: Card) {
  return cards.map((card) => (card.id === updated.id ? updated : card));
}

export const useCardsStore = create<CardsState>()((set) => ({
  cards: [],
  status: 'idle',
  error: null,
  filters: DEFAULT_FILTERS,

  loadCards: async () => {
    set({ status: 'loading', error: null });
    try {
      const cards = await cardsApi.getCards();
      set({ cards, status: 'ready' });
    } catch (err) {
      set({ status: 'error', error: getErrorMessage(err) });
    }
  },

  createCard: async (body) => {
    const card = await cardsApi.createCard(body);
    set((state) => ({ cards: [card, ...state.cards] }));
    return card;
  },

  updateCard: async (cardId, changes) => {
    const card = await cardsApi.updateCard(cardId, changes);
    set((state) => ({ cards: replaceCard(state.cards, card) }));
    return card;
  },

  deleteCard: async (cardId) => {
    await cardsApi.deleteCard(cardId);
    set((state) => ({ cards: state.cards.filter((card) => card.id !== cardId) }));
  },

  vote: async (cardId, value) => {
    const card = value ? await cardsApi.voteCard(cardId, value) : await cardsApi.removeVote(cardId);
    set((state) => ({ cards: replaceCard(state.cards, card) }));
    return card;
  },

  changeCommentsCount: (cardId, delta) =>
    set((state) => ({
      cards: state.cards.map((card) =>
        card.id === cardId ? { ...card, comments_count: card.comments_count + delta } : card,
      ),
    })),

  setFilters: (changes) => set((state) => ({ filters: { ...state.filters, ...changes } })),
}));
  • Каждое действие меняет состояние только после ответа сервера и берёт данные из ответа.

  • replaceCard создаёт новый массив, в котором заменена одна карточка. Остальные карточки остаются теми же объектами, и memo у их превью продолжает работать.

  • vote(cardId, null) отзывает голос. Кнопкам голосования не нужно знать, что за этим стоит отдельный эндпоинт.

  • changeCommentsCount обновляет счётчик комментариев на превью. Сами комментарии загружаются в модальном окне и в сторе не хранятся.

  • Фильтры тоже живут в сторе. В отличие от контекста на странице доски, они не сбрасываются при уходе на другую страницу.

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

Почитать ещё

Где обрабатывать ошибки действий

В сторе карточек ошибки обрабатываются по-разному. loadCards перехватывает ошибку и записывает её в состояние. А createCard, updateCard, deleteCard и vote ошибку не перехватывают: промис, который они возвращают, отклоняется, и ошибку получает тот, кто вызвал действие.

Причина в том, где ошибку показывают.

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

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

Поэтому компоненты вызывают действия так же, как вызывали функции запросов, и сами обрабатывают ошибки:

export function VoteButtons({ card }: { card: Card }) {
  const vote = useCardsStore((state) => state.vote);
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleVote(value: VoteValue) {
    setPending(true);
    setError(null);
    try {
      await vote(card.id, card.my_vote === value ? null : value);
    } catch (err) {
      setError(getErrorMessage(err));
    } finally {
      setPending(false);
    }
  }

  const disabled = pending || card.is_mine;
  // ...кнопки
}

Состояния pending и error здесь локальные: они относятся только к этим кнопкам. После успешного голосования стор заменит карточку, и новый счёт увидят и модальное окно, и превью на доске, и страница пользователя.

Почитать ещё

Стор текущего пользователя

Второй стор ITAM Board хранит текущего пользователя — владельца токена.

// src/entities/session/model/auth-store.ts
import { create } from 'zustand';
import { getErrorMessage, type Profile, type ProfileUpdate } from '@/shared/api';
import { getMe, updateMe } from '../api/session-api';

type AuthState = {
  me: Profile | null;
  error: string | null;
  loadMe: () => Promise<void>;
  saveProfile: (changes: ProfileUpdate) => Promise<void>;
};

export const useAuthStore = create<AuthState>()((set) => ({
  me: null,
  error: null,

  loadMe: async () => {
    set({ error: null });
    try {
      set({ me: await getMe() });
    } catch (err) {
      set({ error: getErrorMessage(err) });
    }
  },

  saveProfile: async (changes) => {
    set({ me: await updateMe(changes) });
  },
}));

Пользователя загружают один раз при запуске приложения, в макете:

export function Layout() {
  const error = useAuthStore((state) => state.error);
  const loadMe = useAuthStore((state) => state.loadMe);

  useEffect(() => {
    loadMe();
  }, [loadMe]);

  return (
    <>
      <Header />
      <main className={styles.main}>
        {error ? <ErrorMessage message={error} onRetry={loadMe} /> : <Outlet />}
      </main>
    </>
  );
}
  • Если токен не подошёл, сервер не отдаст никаких данных. Вместо страниц макет показывает причину и кнопку повтора.

  • Шапка читает me из стора и показывает аватар и имя.

  • Страница пользователя определяет, свой ли это профиль, сравнивая me?.id с userId из адреса.

  • Форма профиля сохраняет изменения через saveProfile. Стор запишет обновлённый профиль, и шапка покажет новое имя без каких-либо дополнительных действий.

Почитать ещё

Селекторы

Селектор — функция, которую передают в хук стора. Она получает всё состояние и возвращает нужную компоненту часть.

const cards = useCardsStore((state) => state.cards);
const status = useCardsStore((state) => state.status);
const setFilters = useCardsStore((state) => state.setFilters);

После каждого изменения стора Zustand вызывает селекторы всех подписанных компонентов и сравнивает результат с предыдущим через Object.is. Если результат тот же, компонент не перерисовывается. Если изменился, перерисовывается.

Благодаря селекторам компонент перерисовывается только при изменении нужных ему данных:

  • компонент фильтров подписан на state.filters и не перерисовывается, когда пользователь голосует;

  • модальное окно подписано на одну карточку и перерисовывается, когда меняется именно она;

  • кнопки, которым нужно только действие, никогда не перерисовываются из-за стора: действия не меняются.

Если вызвать хук без селектора, useCardsStore() вернёт всё состояние, и компонент будет перерисовываться при любом изменении стора. Так делать не нужно.

Модальное окно находит свою карточку селектором с find:

const card = useCardsStore((state) => state.cards.find((item) => item.id === cardId));

find возвращает объект, который уже лежит в массиве. Пока карточка не изменилась, селектор возвращает тот же объект, и окно не перерисовывается. После голосования стор заменит объект карточки, селектор вернёт новый объект, и окно покажет новый счёт.

Почитать ещё

Селекторы, которые создают новые значения

Самая частая ошибка при работе со стором — селектор, который создаёт новый массив или объект.

// Неправильно: filter каждый раз возвращает новый массив
const myCards = useCardsStore((state) => state.cards.filter((card) => card.is_mine));

// Неправильно: каждый раз создаётся новый объект
const { cards, status } = useCardsStore((state) => ({ cards: state.cards, status: state.status }));

Новый массив или объект никогда не равен предыдущему по ссылке. Zustand решает, что результат изменился, и перерисовывает компонент. При следующей проверке селектор снова создаёт новое значение, и так по кругу. В Zustand 5 такой селектор приводит к бесконечному циклу рендеров, и React останавливает приложение с ошибкой Maximum update depth exceeded.

Правильно выбирать из стора исходные данные, а вычислять производные значения в компоненте:

// Выбрать исходные данные, вычислить в useMemo
const cards = useCardsStore((state) => state.cards);
const userCards = useMemo(() => cards.filter((card) => card.author.id === userId), [cards, userId]);

// Несколько полей — несколько селекторов
const cards = useCardsStore((state) => state.cards);
const status = useCardsStore((state) => state.status);

Так ITAM Board вычисляет отфильтрованные карточки на доске и карточки автора на странице пользователя.

Простое правило: селектор возвращает поле состояния или элемент, который уже лежит в состоянии, например результат find. Если в селекторе появились map, filter, { ... } или [ ... ], вычисление нужно перенести в useMemo.

Почитать ещё

  • React: useMemo

Несколько полей одним селектором

Если компоненту нужно несколько полей стора, удобнее всего написать несколько селекторов. Но если хочется получить поля одним вызовом, в Zustand есть функция useShallow:

import { useShallow } from 'zustand/react/shallow';

const { cards, status, error } = useCardsStore(
  useShallow((state) => ({ cards: state.cards, status: state.status, error: state.error })),
);

useShallow меняет способ сравнения результата селектора. Вместо сравнения по ссылке объект сравнивается поверхностно: по каждому полю. Если cards, status и error остались теми же, результат считается прежним, хотя объект создан заново. Бесконечного цикла из прошлого раздела не будет.

useShallow работает и с массивами:

const [loadCards, setFilters] = useCardsStore(useShallow((state) => [state.loadCards, state.setFilters]));

Поверхностное сравнение проверяет только верхний уровень. Если в объекте результата лежит массив, созданный через filter, он по-прежнему будет новым при каждом вызове, и useShallow не поможет. Производные данные всё так же вычисляют в useMemo.

В ITAM Board используются отдельные селекторы: так проще читать код и не нужно помнить об особенностях сравнения.

Почитать ещё

Стор вне компонентов

Стор Zustand существует независимо от React. У хука, который вернула create, есть методы для работы со стором из обычного кода.

// Прочитать текущее состояние
const { cards, filters } = useCardsStore.getState();

// Вызвать действие
useCardsStore.getState().loadCards();

// Изменить состояние напрямую
useCardsStore.setState({ filters: DEFAULT_FILTERS });

// Подписаться на изменения
const unsubscribe = useCardsStore.subscribe((state, previousState) => {
  if (state.cards.length !== previousState.cards.length) {
    console.log('Число карточек изменилось:', state.cards.length);
  }
});
  • getState() возвращает текущее состояние и не подписывает на изменения. Его используют в функциях вне компонентов, в тестах и в обработчиках, где нужно значение на момент вызова.

  • setState работает так же, как set внутри стора.

  • subscribe вызывает функцию при каждом изменении стора и возвращает функцию отписки.

Внутри компонентов стор читают только через хук с селектором. Значение из getState() в рендере не обновится при изменении стора, и компонент покажет устаревшие данные.

Действие стора может прочитать текущее состояние через второй аргумент функции стора — get:

export const useCardsStore = create<CardsState>()((set, get) => ({
  // ...
  reloadIfEmpty: async () => {
    if (get().cards.length === 0) {
      await get().loadCards();
    }
  },
}));

Почитать ещё

Что оставить в useState

После появления стора легко начать складывать в него всё подряд. Но стор нужен не для всего состояния приложения.

В стор кладут:

  • данные с сервера, которые нужны нескольким страницам или компонентам: карточки, текущий пользователь;

  • состояние, которое должно пережить переход между страницами: фильтры доски;

  • действия, которые меняют эти данные.

В useState оставляют:

  • состояние интерфейса одного компонента: открыто ли модальное окно, какая карточка выбрана на этой странице, включён ли режим редактирования профиля;

  • состояние отправки и ошибку конкретного действия: pending и error у кнопок голосования;

  • значения полей формы;

  • данные, которые нужны одному компоненту и не должны сохраняться: комментарии в модальном окне загружаются при открытии и выбрасываются при закрытии.

Признак того, что данные пора перенести в стор: их нужно передавать через пропсы между страницами, или разные компоненты держат свои копии одних и тех же данных. Признак того, что данные не нужны в сторе: их использует только один компонент, и при уходе со страницы они больше не нужны.

Сторы делят по сущностям: стор карточек, стор пользователя. Один большой стор для всего приложения труднее читать, и в нём сложнее понять, какие компоненты от чего зависят.

Почитать ещё

Middleware: devtools и persist

Zustand поддерживает middleware — функции, которые оборачивают стор и добавляют ему возможности. Две из них встречаются чаще других.

devtools подключает стор к расширению Redux DevTools. В расширении видна история изменений стора: какое действие что изменило, и как выглядело состояние после каждого изменения.

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

export const useCardsStore = create<CardsState>()(
  devtools(
    (set) => ({
      // ...состояние и действия
    }),
    { name: 'cards' },
  ),
);

persist сохраняет стор в localStorage и восстанавливает его при загрузке страницы.

import { persist } from 'zustand/middleware';

export const useCardsStore = create<CardsState>()(
  persist(
    (set) => ({
      // ...состояние и действия
    }),
    {
      name: 'itam-board-cards',
      partialize: (state) => ({ filters: state.filters }),
    },
  ),
);
  • name — ключ, под которым стор сохраняется в localStorage.

  • partialize выбирает, что сохранять. Здесь сохраняются только фильтры. Карточки сохранять не нужно: после перезагрузки их всё равно нужно получить с сервера, иначе пользователь увидит устаревшие данные.

Middleware можно вкладывать друг в друга: devtools(persist(...)).

Почитать ещё

Задания