Перейти к содержимому

56. Zustand Slices и TypeScript

TypeScript: Zustand Slices и Мощный Менеджмент Состояния

Заголовок раздела «TypeScript: Zustand Slices и Мощный Менеджмент Состояния»

Привет, кодеры! 👋 Яша снова с вами, и сегодня мы погрузимся в одну из самых элегантных и мощных концепций Zustand для управления состоянием в крупных приложениях: “слайсы” (slices). Если вы уже освоили основы Zustand, то этот урок поможет вам вывести вашу архитектуру на новый уровень модульности и поддерживаемости.

Представьте, что ваше глобальное состояние — это огромный, вкусный пирог. По мере роста приложения этот пирог становится всё больше, его рецепт — сложнее, а ингредиенты — многочисленнее. Пытаться управлять всем пирогом сразу становится очень неудобно. Что, если мы могли бы разрезать этот пирог на аккуратные, специализированные кусочки, каждый со своим уникальным вкусом (состоянием) и способом приготовления (действиями)? Именно это и делают “слайсы” в Zustand!

В больших приложениях, когда весь ваш стор Zustand управляется одним большим файлом useStore.ts, вы очень быстро столкнетесь с проблемами:

  1. Монолитность: Один файл содержит сотни строк кода, десятки стейтов и экшенов, не связанных напрямую друг с другом.
  2. Сложность: Трудно понять, какая часть состояния относится к какому компоненту или функциональности.
  3. Масштабируемость: Добавление новой функциональности приводит к раздуванию и без того большого файла.
  4. Коллизии: Больше шансов случайно изменить или удалить нужную логику.
  5. Тестирование: Тестировать отдельные части стора становится сложнее.

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

Давайте начнем с простого примера. Представим, что у нас есть приложение с двумя основными областями: счетчик медведей (ну а куда без них?) и список задач. Мы хотим, чтобы каждый из них был отдельным слайсом.

Сначала определим интерфейсы для каждого слайса. Это очень важно для обеспечения типобезопасности.

import { create, StoreApi, UseBoundStore } from 'zustand';
// 1. Интерфейс для состояния слайса медведей
interface BearState {
bears: number;
increasePopulation: () => void;
removeAllBears: () => void;
}
// 2. Интерфейс для состояния слайса задач
interface Task {
id: string;
text: string;
completed: boolean;
}
interface TaskState {
tasks: Task[];
addTask: (text: string) => void;
toggleTask: (id: string) => void;
}
// 3. Объединяем все интерфейсы в один общий стор
// Это тип для всего глобального состояния
type AppStoreState = BearState & TaskState;

Теперь создадим функции, которые будут “определять” каждый слайс. Эти функции будут принимать set и get из Zustand, но с типом всего AppStoreState, чтобы внутри слайса мы могли видеть всё глобальное состояние, если это необходимо.

// Функция-создатель для слайса медведей
// T - это тип всего стора, который будет передан create
const createBearSlice = (set: StoreApi<AppStoreState>['setState'], get: StoreApi<AppStoreState>['getState']): BearState => ({
bears: 0,
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
});
// Функция-создатель для слайса задач
const createTaskSlice = (set: StoreApi<AppStoreState>['setState'], get: StoreApi<AppStoreState>['getState']): TaskState => ({
tasks: [],
addTask: (text: string) =>
set(state => ({
tasks: [
...state.tasks,
{ id: crypto.randomUUID(), text, completed: false }, // Используем crypto.randomUUID() для уникального ID
],
})),
toggleTask: (id: string) =>
set(state => ({
tasks: state.tasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
),
})),
});

Самое интересное начинается, когда мы комбинируем эти функции-создатели в наш основной хук useAppStore.

// Создаем главный стор, объединяя все слайсы
const useAppStore: UseBoundStore<StoreApi<AppStoreState>> = create<AppStoreState>()((set, get) => ({
...createBearSlice(set, get), // Разворачиваем состояние и действия слайса медведей
...createTaskSlice(set, get), // Разворачиваем состояние и действия слайса задач
}));
// Пример использования (это не MDX, просто для демонстрации)
/*
function BearCounter() {
const bears = useAppStore(state => state.bears);
const increasePopulation = useAppStore(state => state.increasePopulation);
const removeAllBears = useAppStore(state => state.removeAllBears);
return (
<div>
<h1>Медведей: {bears}</h1>
<button onClick={increasePopulation}>Добавить медведя</button>
<button onClick={removeAllBears}>Убрать всех медведей</button>
</div>
);
}
function TaskList() {
const tasks = useAppStore(state => state.tasks);
const addTask = useAppStore(state => state.addTask);
const toggleTask = useAppStore(state => state.toggleTask);
const [newTaskText, setNewTaskText] = useState('');
return (
<div>
<h2>Задачи:</h2>
<input
type="text"
value={newTaskText}
onChange={(e) => setNewTaskText(e.target.value)}
placeholder="Новая задача..."
/>
<button onClick={() => { addTask(newTaskText); setNewTaskText(''); }}>Добавить</button>
<ul>
{tasks.map(task => (
<li key={task.id} style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
{task.text}
<button onClick={() => toggleTask(task.id)}>
{task.completed ? 'Отметить как невыполненную' : 'Отметить как выполненную'}
</button>
</li>
))}
</ul>
</div>
);
}
// В вашем приложении React:
// <BearCounter />
// <TaskList />
*/

Продвинутые Техники: Взаимодействие Между Слайсами

Заголовок раздела «Продвинутые Техники: Взаимодействие Между Слайсами»

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

Давайте расширим наш пример: медведи теперь могут не только размножаться, но и выполнять задачи!

// Обновленный интерфейс для медведя, теперь он может выполнять задачи
interface BearStateWithInteractions {
bears: number;
increasePopulation: () => void;
removeAllBears: () => void;
// Новое действие: медведь выполняет задачу
bearCompletesTask: (taskId: string) => void;
}
// Обновленный создатель слайса медведей
const createBearSliceWithInteractions = (set: StoreApi<AppStoreState>['setState'], get: StoreApi<AppStoreState>['getState']): BearStateWithInteractions => ({
bears: 0,
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
bearCompletesTask: (taskId: string) => {
// Получаем текущее состояние всего стора через get()
const currentTasks = get().tasks;
const taskExists = currentTasks.some(task => task.id === taskId);
if (taskExists) {
console.log(`Медведь выполняет задачу: ${taskId}`);
// Вызываем действие из другого слайса через get()
get().toggleTask(taskId);
} else {
console.warn(`Задача с ID ${taskId} не найдена. Медведь грустит.`);
}
},
});
// Пересоздаем главный стор с обновленным слайсом медведей
const useAppStoreWithInteractions: UseBoundStore<StoreApi<AppStoreState>> = create<AppStoreState>()((set, get) => ({
...createBearSliceWithInteractions(set, get), // Используем обновленный слайс
...createTaskSlice(set, get),
}));
// Пример использования действия bearCompletesTask
/*
function BearTasker() {
const bearCompletesTask = useAppStoreWithInteractions(state => state.bearCompletesTask);
const tasks = useAppStoreWithInteractions(state => state.tasks);
const [taskIdToComplete, setTaskIdToComplete] = useState('');
return (
<div>
<h3>Медведь-Помощник</h3>
<input
type="text"
value={taskIdToComplete}
onChange={(e) => setTaskIdToComplete(e.target.value)}
placeholder="ID задачи для медведя..."
/>
<button onClick={() => bearCompletesTask(taskIdToComplete)}>
Медведь, выполни задачу!
</button>
<ul>
{tasks.map(task => (
<li key={task.id}>
{task.id}: {task.text} - {task.completed ? 'Выполнена' : 'Не выполнена'}
</li>
))}
</ul>
</div>
);
}
// <BearTasker />
*/

Обратите внимание, что bearCompletesTask использует get().toggleTask(taskId) для вызова действия из TaskSlice. Это мощный механизм, но использовать его нужно обдуманно, чтобы не создавать слишком тесных связей между слайсами.

  1. Неправильная типизация set и get в создателях слайсов:

    • Ошибка: Использование set: any или неверного типа, из-за чего внутри слайса set или get не знает о существовании состояний или действий из других слайсов.
    • Решение: Всегда передавайте StoreApi<AppStoreState>['setState'] и StoreApi<AppStoreState>['getState'] (или их аналоги) в ваши создатели слайсов, где AppStoreState – это общий тип всего вашего стора. Это гарантирует, что set и get всегда имеют полный контекст глобального состояния.
  2. Прямое изменение состояния (мутация) вне set:

    • Ошибка:
      // Внутри слайса:
      const addTask: (text: string) => void = (text) => {
      get().tasks.push({ id: crypto.randomUUID(), text, completed: false }); // Прямая мутация!
      };
    • Решение: Всегда используйте set для обновления состояния. Zustand использует иммутабельность, и прямая мутация может привести к непредсказуемым ошибкам и отсутствию перерендеринга компонентов.
      const addTask: (text: string) => void = (text) =>
      set(state => ({
      tasks: [...state.tasks, { id: crypto.randomUUID(), text, completed: false }],
      }));
  3. Чрезмерная связанность или циклические зависимости:

    • Ошибка: Когда слайсы слишком сильно зависят друг от друга, или A вызывает B, а B вызывает A. Это усложняет понимание потока данных и может привести к трудноотлавливаемым багам.
    • Решение: Старайтесь делать слайсы максимально независимыми. Если взаимодействие необходимо, используйте get() для чтения состояния из других слайсов и для вызова их действий, но избегайте ситуации, когда изменение в слайсе A всегда триггерит сложное изменение в слайсе B, которое затем влияет на A. Иногда лучше вынести общую логику в утилитарную функцию или создать “координирующий” экшен в корневом сторе.

Время закрепить знания! Создайте новое Zustand-приложение или расширьте существующее, используя концепцию слайсов.

Создайте новый слайс UserSlice, который будет управлять информацией о текущем пользователе.

  • Состояние:
    • isLoggedIn: boolean (по умолчанию false)
    • user: { id: string; username: string; email: string; } | null (по умолчанию null)
  • Действия:
    • login(username: string, email: string): void: Устанавливает isLoggedIn в true и заполняет объект user.
    • logout(): void: Сбрасывает isLoggedIn в false и user в null.
    • updateProfile(newUsername: string): void: Обновляет username текущего пользователя (только если isLoggedIn равно true).

Создайте CartSlice для управления товарами в корзине.

  • Состояние:
    • items: { productId: string; name: string; price: number; quantity: number; }[] (по умолчанию пустой массив).
  • Действия:
    • addItem(productId: string, name: string, price: number): void: Добавляет товар в корзину или увеличивает его количество, если он уже есть.
    • removeItem(productId: string): void: Удаляет товар из корзины.
    • updateQuantity(productId: string, quantity: number): void: Обновляет количество товара. Если quantity <= 0, товар должен быть удален.
    • clearCart(): void: Очищает всю корзину.

Задание 3: Взаимодействие “Пользователь” и “Корзина”

Заголовок раздела «Задание 3: Взаимодействие “Пользователь” и “Корзина”»

Модифицируйте ваши слайсы для реализации следующей логики:

  • Зависимость addItem от UserSlice:
    • Метод addItem в CartSlice должен проверять, залогинен ли пользователь (используя get() для доступа к UserSlice). Если пользователь не залогинен, товар не должен добавляться в корзину, и в консоль должно выводиться предупреждение.
  • Зависимость clearCart от UserSlice (Бонус):
    • Метод clearCart должен быть доступен только если пользователь залогинен. Если пользователь не залогинен, корзина не очищается.

Объедините UserSlice, CartSlice и (по желанию) BearSlice из урока в единый AppStoreState и useAppStore.

Думайте о ваших слайсах как о микросервисах внутри вашего фронтенда. Каждый отвечает за свою узкую область, имеет свой API (состояние + действия) и взаимодействует с другими только через четко определенные контракты (типизированный get). Это делает вашу архитектуру предсказуемой и легко масштабируемой. Не бойтесь создавать много маленьких, сфокусированных слайсов — это гораздо лучше, чем один огромный.