56. Zustand Slices и TypeScript
TypeScript: Zustand Slices и Мощный Менеджмент Состояния
Заголовок раздела «TypeScript: Zustand Slices и Мощный Менеджмент Состояния»Привет, кодеры! 👋 Яша снова с вами, и сегодня мы погрузимся в одну из самых элегантных и мощных концепций Zustand для управления состоянием в крупных приложениях: “слайсы” (slices). Если вы уже освоили основы Zustand, то этот урок поможет вам вывести вашу архитектуру на новый уровень модульности и поддерживаемости.
Представьте, что ваше глобальное состояние — это огромный, вкусный пирог. По мере роста приложения этот пирог становится всё больше, его рецепт — сложнее, а ингредиенты — многочисленнее. Пытаться управлять всем пирогом сразу становится очень неудобно. Что, если мы могли бы разрезать этот пирог на аккуратные, специализированные кусочки, каждый со своим уникальным вкусом (состоянием) и способом приготовления (действиями)? Именно это и делают “слайсы” в Zustand!
Зачем нам “слайсы” в Zustand?
Заголовок раздела «Зачем нам “слайсы” в Zustand?»В больших приложениях, когда весь ваш стор Zustand управляется одним большим файлом useStore.ts, вы очень быстро столкнетесь с проблемами:
- Монолитность: Один файл содержит сотни строк кода, десятки стейтов и экшенов, не связанных напрямую друг с другом.
- Сложность: Трудно понять, какая часть состояния относится к какому компоненту или функциональности.
- Масштабируемость: Добавление новой функциональности приводит к раздуванию и без того большого файла.
- Коллизии: Больше шансов случайно изменить или удалить нужную логику.
- Тестирование: Тестировать отдельные части стора становится сложнее.
Слайсы решают эти проблемы, позволяя вам инкапсулировать связанное состояние и логику в отдельные, независимые модули. Каждый слайс становится “владельцем” своей части пирога, обеспечивая четкое разделение ответственности.
Основы Создания Слайсов с TypeScript
Заголовок раздела «Основы Создания Слайсов с TypeScript»Давайте начнем с простого примера. Представим, что у нас есть приложение с двумя основными областями: счетчик медведей (ну а куда без них?) и список задач. Мы хотим, чтобы каждый из них был отдельным слайсом.
Сначала определим интерфейсы для каждого слайса. Это очень важно для обеспечения типобезопасности.
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 - это тип всего стора, который будет передан createconst 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. Это мощный механизм, но использовать его нужно обдуманно, чтобы не создавать слишком тесных связей между слайсами.
Типичные Ошибки и Их Решения
Заголовок раздела «Типичные Ошибки и Их Решения»-
Неправильная типизация
setиgetв создателях слайсов:- Ошибка: Использование
set: anyили неверного типа, из-за чего внутри слайсаsetилиgetне знает о существовании состояний или действий из других слайсов. - Решение: Всегда передавайте
StoreApi<AppStoreState>['setState']иStoreApi<AppStoreState>['getState'](или их аналоги) в ваши создатели слайсов, гдеAppStoreState– это общий тип всего вашего стора. Это гарантирует, чтоsetиgetвсегда имеют полный контекст глобального состояния.
- Ошибка: Использование
-
Прямое изменение состояния (мутация) вне
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 }],}));
- Ошибка:
-
Чрезмерная связанность или циклические зависимости:
- Ошибка: Когда слайсы слишком сильно зависят друг от друга, или A вызывает B, а B вызывает A. Это усложняет понимание потока данных и может привести к трудноотлавливаемым багам.
- Решение: Старайтесь делать слайсы максимально независимыми. Если взаимодействие необходимо, используйте
get()для чтения состояния из других слайсов и для вызова их действий, но избегайте ситуации, когда изменение в слайсе A всегда триггерит сложное изменение в слайсе B, которое затем влияет на A. Иногда лучше вынести общую логику в утилитарную функцию или создать “координирующий” экшен в корневом сторе.
🎯 Практика
Заголовок раздела «🎯 Практика»Время закрепить знания! Создайте новое Zustand-приложение или расширьте существующее, используя концепцию слайсов.
Задание 1: Создание Слайса Пользователя
Заголовок раздела «Задание 1: Создание Слайса Пользователя»Создайте новый слайс 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).
Задание 2: Слайс Корзины Покупок
Заголовок раздела «Задание 2: Слайс Корзины Покупок»Создайте 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). Это делает вашу архитектуру предсказуемой и легко масштабируемой. Не бойтесь создавать много маленьких, сфокусированных слайсов — это гораздо лучше, чем один огромный.