60. Valtio vs Redux/Zustand: когда что использовать
TypeScript: Valtio vs Redux/Zustand – Выбираем свой подход к состоянию
Заголовок раздела «TypeScript: Valtio vs Redux/Zustand – Выбираем свой подход к состоянию»Привет, коллеги-кодеры! Сегодня мы погрузимся в увлекательный мир управления состоянием в React-приложениях. Если вы уже работали с React, то знаете, что состояние — это сердце любого интерактивного приложения. И с ростом сложности вашего проекта, управление этим сердцем может стать настоящим вызовом.
Мы уже знакомы с базовыми понятиями, а значит, готовы выйти за рамки useState и useReducer. Сегодня на ринге — три тяжеловеса, но с абсолютно разными стилями боя: Redux (в современной ипостаси с Redux Toolkit), Zustand и Valtio. Каждый из них предлагает свой уникальный подход к решению одной и той же задачи: как эффективно хранить и изменять данные, чтобы ваше приложение оставалось быстрым, предсказуемым и легким в поддержке.
Поехали!
🚀 Фундаментальные отличия: Мутация vs. Иммутация
Заголовок раздела «🚀 Фундаментальные отличия: Мутация vs. Иммутация»Прежде чем мы углубимся в синтаксис, давайте разберемся с главной философской разницей между этими библиотеками.
Иммутабельность (Immutable State): Redux, Zustand Эти библиотеки построены на идее, что состояние никогда не должно изменяться напрямую. Вместо этого, при каждом изменении создается новая копия состояния.
- Плюсы: Предсказуемость, легко отслеживать изменения (просто сравнивая старое и новое состояние), упрощает “time-travel debugging”, хорошо работает с React.memo и
useMemoдля оптимизации. - Минусы: Может требовать больше “boilerplate” кода для создания новых объектов, глубокое клонирование может быть затратным, особенно для больших, вложенных объектов.
Мутабельность с реактивностью (Mutable State with Reactivity): Valtio Valtio использует JavaScript Proxies, чтобы позволить вам “мутировать” состояние напрямую, но при этом автоматически отслеживать изменения и вызывать ререндер компонентов, которые используют измененные части.
- Плюсы: Минимальный boilerplate, очень интуитивный синтаксис (похож на работу с обычными объектами), эффективно оптимизирует ререндеры, затрагивая только те компоненты, чьи данные изменились.
- Минусы: Может быть менее привычно для разработчиков, привыкших к иммутабельности. Требует понимания, как работают Proxy, чтобы избежать ловушек (например, забыть проксировать вложенные объекты).
🛠️ Redux (c Redux Toolkit): Проверенный боец
Заголовок раздела «🛠️ Redux (c Redux Toolkit): Проверенный боец»Redux с Redux Toolkit (RTK) — это де-факто стандарт для больших и сложных приложений. Он предоставляет централизованное хранилище, строгий поток данных (actions -> reducers -> new state) и мощные инструменты для отладки.
Когда использовать:
- Большие приложения с глобальным состоянием, которое часто используется в разных частях.
- Требуется строгий контроль за изменениями состояния и их отслеживание.
- Нужна интеграция с middleware (логирование, асинхронные операции, нормализация данных).
- В команде есть много разработчиков, и нужна единая, предсказуемая архитектура.
Пример: Управление пользователями и счетчиком загрузок
Заголовок раздела «Пример: Управление пользователями и счетчиком загрузок»import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
// Определение типа для пользователяinterface User { id: string; name: string; email: string;}
// Определение типа для состояния пользователяinterface UserState { users: User[]; isLoading: boolean; error: string | null; loadCount: number; // Счетчик загрузок}
// Начальное состояниеconst initialState: UserState = { users: [], isLoading: false, error: null, loadCount: 0,};
// Асинхронный thunk для получения пользователейexport const fetchUsers = createAsyncThunk( 'user/fetchUsers', async (_, { rejectWithValue }) => { try { // Имитация API запроса const response = await new Promise<User[]>(resolve => setTimeout(() => resolve([ ]), 1000) ); return response; } catch (error: any) { return rejectWithValue(error.message); } });
// Создание "среза" (slice) для пользователейconst userSlice = createSlice({ name: 'user', initialState, reducers: { // Синхронный редюсер для добавления пользователя addUser: (state, action: PayloadAction<User>) => { state.users.push(action.payload); // RTK позволяет "мутировать" состояние благодаря Immer }, // Синхронный редюсер для очистки списка clearUsers: (state) => { state.users = []; } }, // Обработка асинхронных действий extraReducers: (builder) => { builder .addCase(fetchUsers.pending, (state) => { state.isLoading = true; state.error = null; state.loadCount += 1; // Увеличиваем счетчик при начале загрузки }) .addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => { state.isLoading = false; state.users = action.payload; }) .addCase(fetchUsers.rejected, (state, action) => { state.isLoading = false; state.error = action.payload as string; }); },});
export const { addUser, clearUsers } = userSlice.actions;export default userSlice.reducer;
// src/store/index.tsimport { configureStore } from '@reduxjs/toolkit';import userReducer from './userSlice';
export const store = configureStore({ reducer: { user: userReducer, },});
// Вывод типов RootState и AppDispatch для использования в хукахexport type RootState = ReturnType<typeof store.getState>;export type AppDispatch = typeof store.dispatch;
// src/App.tsx (или любой другой компонент)import React, { useEffect } from 'react';import { useSelector, useDispatch } from 'react-redux';import { RootState, AppDispatch } from './store';import { fetchUsers, addUser, clearUsers } from './store/userSlice';
function UserDisplay() { const dispatch = useDispatch<AppDispatch>(); const { users, isLoading, error, loadCount } = useSelector((state: RootState) => state.user);
useEffect(() => { // Загрузка пользователей при монтировании компонента dispatch(fetchUsers()); }, [dispatch]);
const handleAddUser = () => { const newUser = { id: Date.now().toString(), name: `Новый Юзер ${loadCount}`, email: `new${loadCount}@example.com` }; dispatch(addUser(newUser)); };
return ( <div> <h3>Пользователи (Redux Toolkit)</h3> <p>Загружено {loadCount} раз</p> {isLoading && <p>Загрузка пользователей...</p>} {error && <p style={{ color: 'red' }}>Ошибка: {error}</p>} <ul> {users.map((user) => ( <li key={user.id}>{user.name} ({user.email})</li> ))} </ul> <button onClick={handleAddUser}>Добавить пользователя</button> <button onClick={() => dispatch(clearUsers())}>Очистить список</button> <button onClick={() => dispatch(fetchUsers())}>Загрузить еще раз</button> </div> );}
// Для работы с Redux нужен Provider в корне приложения:// import { Provider } from 'react-redux';// import { store } from './store';// ReactDOM.render(<Provider store={store}><UserDisplay /></Provider>, document.getElementById('root'));🐻❄️ Zustand: Минималистичный и гибкий
Заголовок раздела «🐻❄️ Zustand: Минималистичный и гибкий»Zustand — это гораздо более легковесная и простая альтернатива Redux. Он использует хуки React, но не привязан к React. Позволяет создавать маленькие, изолированные сторы, которые легко комбинировать. Несмотря на то, что Zustand оперирует иммутабельными обновлениями, его API настолько минималистичен, что кажется, будто вы просто обновляете объект.
Когда использовать:
- Средние по размеру приложения, где не требуется полная мощь Redux, но нужно глобальное состояние.
- Приоритет отдается минимальному boilerplate и простоте.
- Для управления локальным состоянием компонента или небольшой фичи, которая может вырасти.
- Вы хотите сохранить иммутабельный подход, но с гораздо меньшим количеством кода.
Пример: Тот же счетчик загрузок и пользователи
Заголовок раздела «Пример: Тот же счетчик загрузок и пользователи»import { create } from 'zustand';
// Определение типов, как и в Redux примереinterface User { id: string; name: string; email: string;}
interface UserState { users: User[]; isLoading: boolean; error: string | null; loadCount: number; fetchUsers: () => Promise<void>; // Метод для асинхронной загрузки addUser: (user: User) => void; clearUsers: () => void;}
// Создание Zustand стораexport const useUserStore = create<UserState>((set, get) => ({ users: [], isLoading: false, error: null, loadCount: 0,
// Асинхронное действие fetchUsers: async () => { set((state) => ({ ...state, isLoading: true, error: null, loadCount: state.loadCount + 1 })); try { const response = await new Promise<User[]>(resolve => setTimeout(() => resolve([ ]), 1000) ); set((state) => ({ ...state, users: response, isLoading: false })); } catch (error: any) { set((state) => ({ ...state, error: error.message, isLoading: false })); } },
// Синхронные действия addUser: (user) => set((state) => ({ users: [...state.users, user] })), clearUsers: () => set({ users: [] }),}));
// src/App.tsx (или любой другой компонент)import React, { useEffect } from 'react';import { useUserStore } from './store/useUserStore';
function UserDisplayZustand() { // Выбираем только те части состояния, которые нам нужны const users = useUserStore(state => state.users); const isLoading = useUserStore(state => state.isLoading); const error = useUserStore(state => state.error); const loadCount = useUserStore(state => state.loadCount); const fetchUsers = useUserStore(state => state.fetchUsers); const addUser = useUserStore(state => state.addUser); const clearUsers = useUserStore(state => state.clearUsers);
useEffect(() => { fetchUsers(); }, [fetchUsers]); // Зависимость от fetchUsers
const handleAddUser = () => { const newUser = { id: Date.now().toString(), name: `Новый Юзер Zustand ${loadCount}`, email: `new-zustand${loadCount}@example.com` }; addUser(newUser); };
return ( <div> <h3>Пользователи (Zustand)</h3> <p>Загружено {loadCount} раз</p> {isLoading && <p>Загрузка пользователей...</p>} {error && <p style={{ color: 'red' }}>Ошибка: {error}</p>} <ul> {users.map((user) => ( <li key={user.id}>{user.name} ({user.email})</li> ))} </ul> <button onClick={handleAddUser}>Добавить пользователя</button> <button onClick={clearUsers}>Очистить список</button> <button onClick={fetchUsers}>Загрузить еще раз</button> </div> );}🧙♂️ Valtio: Реактивный и интуитивный
Заголовок раздела «🧙♂️ Valtio: Реактивный и интуитивный»Valtio — это относительно новая библиотека, которая кардинально меняет подход к управлению состоянием, используя JavaScript Proxies. Вы просто создаете обычный JavaScript объект и “проксируете” его. Затем вы можете “мутировать” этот объект напрямую, а Valtio позаботится о реактивности и оптимизации ререндеров.
Когда использовать:
- Проекты, где важна максимальная простота взаимодействия со состоянием и минимальный boilerplate.
- Приложения с глубоко вложенными, часто изменяющимися объектами (например, графические редакторы, игровые интерфейсы).
- Когда вы хотите интуитивно “мутировать” данные, не беспокоясь об иммутабельности.
- Когда нужен автоматический и тонкий контроль за рендерами (Valtio обновляет только те компоненты, которые читают измененные части прокси-объекта).
Пример: Тот же счетчик загрузок и пользователи, но с прямой мутацией
Заголовок раздела «Пример: Тот же счетчик загрузок и пользователи, но с прямой мутацией»import { proxy } from 'valtio';
// Определяем типыinterface User { id: string; name: string; email: string;}
interface UserState { users: User[]; isLoading: boolean; error: string | null; loadCount: number;}
// Создаем проксированный объект состоянияexport const userProxy = proxy<UserState>({ users: [], isLoading: false, error: null, loadCount: 0,});
// Асинхронное действие: просто мутируем прокси-объект!export const fetchUsersProxy = async () => { userProxy.isLoading = true; userProxy.error = null; userProxy.loadCount += 1; // Мутируем напрямую
try { const response = await new Promise<User[]>(resolve => setTimeout(() => resolve([ ]), 1000) ); userProxy.users = response; // Снова прямая мутация userProxy.isLoading = false; } catch (error: any) { userProxy.error = error.message; userProxy.isLoading = false; }};
// Синхронные действияexport const addUserProxy = (user: User) => { userProxy.users.push(user); // Мутация массива напрямую};
export const clearUsersProxy = () => { userProxy.users = []; // Переприсвоение массива};
// src/App.tsx (или любой другой компонент)import React, { useEffect } from 'react';import { useSnapshot } from 'valtio';import { userProxy, fetchUsersProxy, addUserProxy, clearUsersProxy } from './store/userProxy';
function UserDisplayValtio() { // Получаем "снимок" (snapshot) состояния для рендера // Valtio автоматически подписывается на изменения тех свойств, которые вы используете const snap = useSnapshot(userProxy);
useEffect(() => { fetchUsersProxy(); }, []); // Пустой массив зависимостей, т.к. функция не зависит от состояния React
const handleAddUser = () => { const newUser = { id: Date.now().toString(), name: `Новый Юзер Valtio ${snap.loadCount}`, email: `new-valtio${snap.loadCount}@example.com` }; addUserProxy(newUser); };
return ( <div> <h3>Пользователи (Valtio)</h3> <p>Загружено {snap.loadCount} раз</p> {snap.isLoading && <p>Загрузка пользователей...</p>} {snap.error && <p style={{ color: 'red' }}>Ошибка: {snap.error}</p>} <ul> {snap.users.map((user) => ( <li key={user.id}>{user.name} ({user.email})</li> ))} </ul> <button onClick={handleAddUser}>Добавить пользователя</button> <button onClick={clearUsersProxy}>Очистить список</button> <button onClick={fetchUsersProxy}>Загрузить еще раз</button> </div> );}🚨 Типичные ошибки и нюансы
Заголовок раздела «🚨 Типичные ошибки и нюансы»Redux/Zustand (Иммутабельность):
-
Прямая мутация состояния: Самая частая и опасная ошибка. Например,
state.users.push(newUser)без RTK илиset(state => { state.users.push(newUser); return state; })в Zustand. Это нарушает принцип иммутабельности, приводит к непредсказуемому поведению, проблемам с дебаггингом и некорректным рендерам.- Решение: Всегда возвращайте новый объект состояния. Используйте оператор spread
...или методы для создания новых массивов/объектов ([...state.users, newUser],{ ...state, users: newUsersArray }). RTK использует Immer, который позволяет писать “мутабельный” код, который под капотом становится иммутабельным.
- Решение: Всегда возвращайте новый объект состояния. Используйте оператор spread
-
Избыточный ререндер: Если селекторы возвращают новый объект или массив при каждом вызове (даже если данные внутри не изменились), компоненты будут перерисовываться.
- Решение: Используйте мемоизированные селекторы (
createSelectorв Redux) или выбирайте только примитивные значения, если возможно.
- Решение: Используйте мемоизированные селекторы (
Valtio (Мутабельность с реактивностью):
-
Забыли
useSnapshot: Прямая мутацияuserProxy.users.push()вне компонентов, использующихuseSnapshot, обновит состояние, но не вызовет ререндер, потому что React не знает об изменениях.useSnapshot— это то, что подписывает компонент на изменения прокси-объекта.- Решение: Всегда используйте
useSnapshot(myProxy)внутри функциональных компонентов React, чтобы компонент реагировал на изменения.
- Решение: Всегда используйте
-
Не проксировали вложенные объекты: Если вы добавляете в проксированный объект обычный JavaScript объект, изменения внутри этого нового объекта не будут отслеживаться Valtio.
const state = proxy({ data: { count: 0 } });// state.data - это просто обычный объект, не прокси// Если мы меняем state.data.count++, Valtio это увидит.// Но если мы сделаем state.data = { someOther: 1 }; а потом state.data.someOther++,// Valtio не будет отслеживать state.data.someOther, потому что state.data была переприсвоена обычным объектом.- Решение: Если вы хотите, чтобы вложенные объекты также были реактивными, создавайте их с помощью
proxy()или убедитесь, что они были изначально частью прокси-объекта при его создании. Valtio автоматически проксирует вложенные объекты при их присвоении, но вы должны быть внимательны, если полностью заменяете ветку состояния. Если вы создаете новый объект для замены существующего, убедитесь, что он также проксирован, если внутри него есть реактивные данные.
- Решение: Если вы хотите, чтобы вложенные объекты также были реактивными, создавайте их с помощью
-
Осторожность с
Object.freeze(): Valtio полагается на возможность мутировать объекты. Если выObject.freeze()прокси-объект или его части, Valtio не сможет работать корректно.
🎯 Практика
Заголовок раздела «🎯 Практика»Время закрепить знания!
Задание 1 (Valtio): Реализация “Список покупок” Создайте глобальное состояние для списка покупок с использованием Valtio.
- Каждый элемент списка должен иметь
id,name,quantity(количество) иisPurchased(куплен или нет). - Реализуйте функции:
addItem(name: string, quantity: number): Добавление нового товара.removeItem(id: string): Удаление товара.togglePurchased(id: string): Переключение статусаisPurchased.updateQuantity(id: string, newQuantity: number): Изменение количества товара.
- Отобразите список в React-компоненте и добавьте кнопки для выполнения этих действий.
Задание 2 (Zustand): Управление темой приложения
Реализуйте глобальное состояние для темы приложения (например, light или dark) с использованием Zustand.
- Создайте стор с состоянием
theme: 'light' | 'dark'и функциейtoggleTheme(). - Сохраняйте выбранную тему в
localStorage, чтобы она сохранялась между сессиями. - В вашем React-компоненте отобразите текущую тему и кнопку для ее переключения.
- Примените соответствующий CSS-класс (
.light-themeили.dark-theme) кdocument.body.
Задание 3 (Сравнение): Выбор библиотеки для формы Представьте, что вам нужно разработать сложную форму обратной связи с множеством полей (имя, email, сообщение, чекбоксы для подписки, поле для загрузки файла). Форма также имеет динамическую валидацию и индикатор прогресса отправки.
- Какую из трех библиотек (
Redux (RTK),Zustand,Valtio) вы бы выбрали для управления состоянием этой формы? - Обоснуйте свой выбор, перечислив плюсы и минусы выбранной библиотеки конкретно для этого сценария. Укажите, как бы вы обрабатывали валидацию и асинхронную отправку данных в рамках выбранного подхода.
💡 Совет
Заголовок раздела «💡 Совет»Выбор инструмента всегда зависит от задачи, а не от того, какой инструмент “лучше” в абсолюте.
- Redux (RTK): Ваш выбор для крупных, долгосрочных проектов с высокой командной нагрузкой. Его строгая структура обеспечивает предсказуемость и отличную отлаживаемость. “Машина”, которая может перевезти тонны груза, но требует опытного водителя.
- Zustand: Идеален для большинства средних проектов или для выделения небольших, независимых частей состояния в больших проектах. Он дает легкость и простоту
useState, но с глобальным доступом и без React Context Hell. “Шустрый городской автомобиль”, который доставит вас куда угодно с комфортом. - Valtio: Блестяще себя показывает в сценариях, где вы работаете с глубоко вложенными, динамичными данными (например, графические редакторы, конфигураторы). Его подход “мутации с реактивностью” может значительно упростить код и повысить производительность, если вы понимаете, как он работает. “Спортивный байк”, который требует мастерства, но дает невероятную свободу и скорость.
Не бойтесь комбинировать! Например, Valtio может быть великолепен для управления временным, сложным UI-состоянием (например, позиция элементов на канвасе), в то время как Zustand или Redux управляют глобальными пользовательскими данными и аутентификацией. Главное — осознанный выбор.