Урок 36 из 49 · Месяц 5. React и Zustand
Знакомство с Zustand
Содержание урока
- Что такое Zustand и зачем он нужен
- Установка
- Первый store: счётчик
- Используем в компонентах
- Селекторы и лишние перерисовки
- Не берите весь store целиком
- Несколько полей сразу: useShallow
- get: чтение состояния внутри действий
- Асинхронные действия
- Сохранение в localStorage: persist
- DevTools: смотрим, что происходит в store
- Работа со store вне компонентов
- Как организовать stores
- Практика
- Итоги
Zustand — один из самых простых и популярных менеджеров состояния для React. Хранилище создаётся одной функцией, не нужен провайдер, а код получается коротким и понятным. В этом уроке разберём все основные моменты: как создать store, читать и менять данные, писать асинхронные действия, сохранять состояние в localStorage и не допускать лишних перерисовок.
Что такое Zustand и зачем он нужен
В уроке обзор менеджеров состояния мы увидели, что глобальное состояние избавляет от prop drilling. Zustand решает эту задачу с минимумом кода:
- store (хранилище) — обычный объект с данными и функциями, которые их меняют;
- хук — store превращается в хук
useXxxStore, который можно вызвать в любом компоненте; - провайдер не нужен — store живёт вне дерева React, как отдельный модуль.
Аналогия: store — это общая доска объявлений в офисе. Любой сотрудник (компонент) может подойти, прочитать нужное объявление или повесить новое. Не нужно передавать записки из рук в руки.
Установка
npm create vite@latest zustand-demo -- --template react
cd zustand-demo
npm install
npm install zustand
Первый store: счётчик
// src/store/useCounterStore.js
import { create } from "zustand";
export const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
Разбор по строкам:
create(...)— создаёт store и возвращает хук. Название по договорённости начинается сuseи заканчивается наStore.(set) => ({ ... })— функция, которая описывает store. Она получаетset— функцию для изменения состояния — и возвращает начальный объект.count: 0— данные (state).increment,decrement,reset— действия (actions): функции, которые меняют данные. Они лежат прямо в store рядом с данными.set((state) => ({ count: state.count + 1 }))— форма с функцией: получаем текущее состояние и возвращаем, что нужно изменить. Используйте её, когда новое значение зависит от старого.set({ count: 0 })— короткая форма: просто передаём новые значения.
Используем в компонентах
// src/components/Counter.jsx
import { useCounterStore } from "../store/useCounterStore";
export function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
const decrement = useCounterStore((state) => state.decrement);
const reset = useCounterStore((state) => state.reset);
return (
<div>
<h2>{count}</h2>
<button onClick={decrement}>−</button>
<button onClick={increment}>+</button>
<button onClick={reset}>Сброс</button>
</div>
);
}
// src/components/Header.jsx — совсем в другом месте дерева
import { useCounterStore } from "../store/useCounterStore";
export function Header() {
const count = useCounterStore((state) => state.count);
return <header>Текущее значение: {count}</header>;
}
Функция (state) => state.count называется селектор: она выбирает из store нужный кусочек. Компонент перерисуется только тогда, когда изменится то, что вернул селектор. Если Header читает только count, то изменения других полей его не затронут.
Селекторы и лишние перерисовки
Не берите весь store целиком
// ❌ Плохо: компонент перерисуется при ЛЮБОМ изменении в store
const { count } = useCounterStore();
// ✅ Хорошо: только при изменении count
const count = useCounterStore((state) => state.count);
Вызов хука без селектора вернёт весь объект. Для маленького счётчика разницы нет, а в большом store это заметно замедлит приложение.
Несколько полей сразу: useShallow
Хочется достать несколько значений одной строкой. Но если селектор каждый раз возвращает новый объект, Zustand посчитает, что данные изменились:
// ❌ Каждый раз новый объект → бесконечные перерисовки / ошибка
const { count, increment } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
}));
Для этого есть useShallow — он сравнивает поля объекта по отдельности (поверхностно):
import { useShallow } from "zustand/react/shallow";
const { count, increment } = useCounterStore(
useShallow((state) => ({ count: state.count, increment: state.increment }))
);
get: чтение состояния внутри действий
Кроме set, функция store получает второй аргумент — get. Он возвращает текущее состояние. Пригодится, когда действию нужно прочитать данные без их изменения.
// src/store/useCartStore.js
import { create } from "zustand";
export const useCartStore = create((set, get) => ({
items: [],
addItem: (product) => {
const exists = get().items.find((item) => item.id === product.id);
if (exists) {
set({
items: get().items.map((item) =>
item.id === product.id ? { ...item, qty: item.qty + 1 } : item
),
});
} else {
set({ items: [...get().items, { ...product, qty: 1 }] });
}
},
removeItem: (id) =>
set((state) => ({ items: state.items.filter((item) => item.id !== id) })),
clear: () => set({ items: [] }),
totalPrice: () =>
get().items.reduce((sum, item) => sum + item.price * item.qty, 0),
}));
Разбор:
addItemсначала черезget()проверяет, есть ли товар в корзине. Если есть — увеличиваетqty(количество), создавая новый массив черезmapи новый объект через spread. Если нет — добавляет товар сqty: 1.totalPrice— функция, которая считает сумму. В компоненте её вызывают так:useCartStore((s) => s.totalPrice()).
function CartSummary() {
const items = useCartStore((s) => s.items);
const total = useCartStore((s) => s.totalPrice());
const clear = useCartStore((s) => s.clear);
return (
<div>
<p>Товаров: {items.length}</p>
<p>Сумма: {total} сом</p>
<button onClick={clear}>Очистить</button>
</div>
);
}
Асинхронные действия
Действие может быть async: загрузить данные с сервера и записать их в store. Zustand не требует ничего особенного — просто вызываем set, когда данные пришли.
// src/store/useUsersStore.js
import { create } from "zustand";
export const useUsersStore = create((set) => ({
users: [],
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true, error: null });
try {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
if (!res.ok) throw new Error("Ошибка загрузки");
const data = await res.json();
set({ users: data, isLoading: false });
} catch (err) {
set({ error: err.message, isLoading: false });
}
},
}));
import { useEffect } from "react";
import { useUsersStore } from "../store/useUsersStore";
export function UsersList() {
const users = useUsersStore((s) => s.users);
const isLoading = useUsersStore((s) => s.isLoading);
const error = useUsersStore((s) => s.error);
const fetchUsers = useUsersStore((s) => s.fetchUsers);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
if (isLoading) return <p>Загрузка...</p>;
if (error) return <p>Ошибка: {error}</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
- Перед запросом ставим
isLoading: trueи сбрасываем прошлую ошибку. try/catch(урок try, catch, finally) ловит сетевые ошибки и нашthrow.- Функции в store не пересоздаются, поэтому
fetchUsersв зависимостяхuseEffectбезопасен.
Сохранение в localStorage: persist
По умолчанию store живёт в памяти и обнуляется после перезагрузки страницы. Middleware persist (промежуточный слой) автоматически сохраняет состояние в localStorage и восстанавливает его при запуске.
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
export const useSettingsStore = create(
persist(
(set) => ({
theme: "light",
language: "ru",
toggleTheme: () =>
set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
setLanguage: (language) => set({ language }),
}),
{
name: "settings-storage",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme, language: state.language }),
}
)
);
persist(описание store, настройки)— оборачиваем нашу функцию.name— ключ, под которым данные лежат вlocalStorage. Откройте DevTools → Application → Local Storage и увидите его (урок JSON, localStorage и DevTools).storage— где хранить.localStorage— по умолчанию; можноsessionStorage.partialize— что именно сохранять. Функции сохранять не нужно, поэтому выбираем только данные.
DevTools: смотрим, что происходит в store
Middleware devtools подключает store к расширению браузера Redux DevTools: вы увидите каждое изменение и состояние до и после.
import { create } from "zustand";
import { devtools } from "zustand/middleware";
export const useCounterStore = create(
devtools(
(set) => ({
count: 0,
increment: () =>
set((state) => ({ count: state.count + 1 }), undefined, "counter/increment"),
}),
{ name: "CounterStore" }
)
);
Третий аргумент set — имя действия, которое отобразится в DevTools. Middleware можно комбинировать: create(devtools(persist((set) => ({...}), {...}))).
Работа со store вне компонентов
Хук из create — ещё и объект с методами. Это полезно в обычных JS-функциях, например в axios-интерцепторе (урок JWT-авторизация):
// прочитать текущее состояние
const count = useCounterStore.getState().count;
// вызвать действие
useCounterStore.getState().increment();
// изменить напрямую
useCounterStore.setState({ count: 10 });
// подписаться на изменения
const unsubscribe = useCounterStore.subscribe((state) => {
console.log("Новое значение:", state.count);
});
Как организовать stores
- Делайте несколько небольших stores по смыслу:
useCartStore,useAuthStore,useSettingsStore. Их проще читать и тестировать. - Храните stores в папке
src/store/(илиsrc/stores/), по одному файлу на store. - Добавляйте действие
reset, чтобы вернуть начальное состояние (например, при выходе из аккаунта):
const initialState = { items: [] };
export const useCartStore = create((set) => ({
...initialState,
addItem: (p) => set((s) => ({ items: [...s.items, p] })),
reset: () => set(initialState),
}));
Подробнее — в официальной документации Zustand.
Практика
- Счётчик. Создайте
useCounterStoreс действиямиincrement,decrement,resetиincrementBy(n). Выведите значение в двух разных компонентах. Ожидаемый результат: оба компонента синхронно показывают одно число. - Список дел. Сделайте
useTodoStoreс полемtodosи действиямиaddTodo(text),toggleTodo(id),removeTodo(id). Форма добавления и список — отдельные компоненты без общих пропсов. - Сохранение. Подключите
persistк списку дел. Ожидаемый результат: после перезагрузки страницы задачи остаются, в Local Storage виден ваш ключ. - Корзина. Реализуйте корзину с
qty,totalPrice(), кнопками «+» и «−» у каждого товара (приqty= 0 товар удаляется). ИспользуйтеuseShallowхотя бы в одном компоненте. - Загрузка с сервера. Сделайте
usePostsStoreс асинхроннымfetchPosts, полямиisLoadingиerror. Подключитеdevtoolsи проверьте в Redux DevTools, что видны все изменения.
Итоги
create((set, get) => ({...}))создаёт store и возвращает хук; провайдер не нужен.- Данные и действия лежат вместе;
setсливает изменения с текущим состоянием,getчитает его. - Читайте данные через селекторы, для нескольких полей —
useShallow. - Состояние нельзя мутировать: всегда новый массив или объект.
persistсохраняет store вlocalStorage,devtoolsпоказывает изменения в Redux DevTools.getState()/setState()позволяют работать со store вне компонентов.