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

59. Valtio Proxies и TypeScript

💡 Введение: Магия Прокси для Реактивного Состояния

Заголовок раздела «💡 Введение: Магия Прокси для Реактивного Состояния»

Привет, кодер! Сегодня мы погрузимся в мир Valtio – это как ниндзя-библиотека для управления состоянием, которая использует силу нативных JavaScript Proxies. Забудьте о скучных бойлерплейтах и сложных редюсерах. Valtio делает реактивное состояние таким же простым, как работа с обычным JavaScript-объектом.

Представь, что твой объект состояния — это священная книга с ценными знаниями. Valtio создает волшебное зеркало (это и есть Proxy) этой книги. Когда ты меняешь что-то в оригинальной книге (твоем состоянии), зеркало немедленно показывает обновленное отражение. И все, кто смотрит в это зеркало (например, твои React-компоненты), мгновенно видят эти изменения и реагируют на них.

Зачем нам TypeScript в этой магии? Потому что даже самая мощная магия должна быть предсказуемой и безопасной. TypeScript — это твой верный щит и меч. Он гарантирует, что ты знаешь, какие главы есть в твоей книге, какой тип информации они содержат, и какие заклинания (действия) ты можешь над ними выполнять. Он не позволит тебе случайно поменять тип “счетчика” на “строку” или добавить свойство, которого нет в плане.

Давай же посмотрим, как эта магия работает на практике!

🧠 Базовая Теория: Создаем Прокси-Состояние

Заголовок раздела «🧠 Базовая Теория: Создаем Прокси-Состояние»

В сердце Valtio лежит функция proxy(). Она берет обычный JavaScript-объект и возвращает его реактивную Proxy-версию. Когда ты изменяешь свойство этого прокси-объекта, Valtio уведомляет всех “подписчиков” (например, компоненты, использующие useSnapshot), что состояние изменилось, и они могут обновиться.

import { proxy } from 'valtio';
// Определяем интерфейс для нашего состояния.
// Это хорошая практика для ясности и строгого контроля типов.
interface CounterState {
count: number;
message: string;
history: number[]; // Добавим что-то посложнее
}
// Создаем прокси-объект. TypeScript автоматически выведет тип
// на основе начального объекта, но явное указание через <CounterState>
// делает код более читаемым и безопасным.
const counterState = proxy<CounterState>({
count: 0,
message: 'Привет, Valtio!',
history: [0],
});
console.log('Начальное состояние:', counterState.count, counterState.message); // 0 Привет, Valtio!
// Изменяем состояние напрямую - это ключевая особенность Valtio!
// TypeScript здесь гарантирует, что мы меняем существующие свойства
// и только на значения правильных типов.
counterState.count++;
counterState.message = 'Счетчик обновился!';
counterState.history.push(counterState.count); // Добавляем текущее значение в историю
console.log('Обновленное состояние:', counterState.count, counterState.message); // 1 Счетчик обновился!
console.log('История:', counterState.history); // [0, 1]
// Заметь: здесь нет вызовов `setState` или сложных редюсеров.
// Просто прямое изменение объекта, как если бы это был обычный JS-объект.

🚀 Практические Примеры: От Простого к Сложному

Заголовок раздела «🚀 Практические Примеры: От Простого к Сложному»

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

### 1. Управление списком задач с интерфейсом

Заголовок раздела «### 1. Управление списком задач с интерфейсом»

Покажем, как Valtio с TypeScript прекрасно справляется с массивами и вложенными объектами.

import { proxy } from 'valtio';
interface Todo {
id: string;
text: string;
completed: boolean;
dueDate?: string; // Опциональное поле
}
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
const todoState = proxy<TodoState>({
todos: [],
filter: 'all',
});
// Функции-действия для изменения состояния
const addTodo = (text: string, dueDate?: string) => {
todoState.todos.push({
id: `todo-${todoState.todos.length + 1}-${Date.now()}`, // Уникальный ID
text,
completed: false,
dueDate,
});
};
const toggleTodo = (id: string) => {
const todo = todoState.todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed; // TypeScript знает, что todo.completed - boolean
}
};
const setFilter = (newFilter: TodoState['filter']) => {
todoState.filter = newFilter; // TypeScript проверит, что newFilter - валидный тип
};
// Добавляем задачи
addTodo('Изучить Valtio', '2023-12-31');
addTodo('Написать классное приложение на TypeScript', '2024-01-15');
addTodo('Поделиться знаниями с друзьями');
// Изменяем задачи
toggleTodo(todoState.todos[0].id); // Переключаем первую задачу
setFilter('active');
console.log('Все задачи:', JSON.stringify(todoState.todos, null, 2));
console.log('Текущий фильтр:', todoState.filter); // active
console.log('Выполнена ли первая задача:', todoState.todos[0].completed); // true

### 2. Использование useSnapshot для React-компонентов

Заголовок раздела «### 2. Использование useSnapshot для React-компонентов»

Это критически важно для работы с Valtio в React. useSnapshot – это хук, который делает две вещи:

  1. Возвращает иммутабельный снимок текущего состояния прокси-объекта.
  2. Подписывает компонент на изменения этого прокси, вызывая его перерисовку при изменении.

Важно: Вы не должны мутировать снимок! Изменения всегда должны происходить через оригинальный прокси-объект. TypeScript поможет вам с этим, сделав тип снимка Readonly<T>.

import { proxy, useSnapshot } from 'valtio';
// В реальном проекте useSnapshot импортируется из 'valtio/react' или 'valtio/utils'
// Для примера просто представим, что useSnapshot доступен и работает как хук.
interface UserProfile {
name: string;
email: string;
age: number;
address: {
street: string;
city: string;
zipCode: string;
};
}
const userProfileState = proxy<UserProfile>({
name: 'Яша Программист',
age: 30,
address: {
street: 'Улица Кода, 101',
city: 'TypeScriptville',
zipCode: 'TS1 0TS',
},
});
// Действия для изменения профиля
const userProfileActions = {
updateName: (newName: string) => {
userProfileState.name = newName;
},
updateEmail: (newEmail: string) => {
userProfileState.email = newEmail;
},
updateAddress: (newStreet: string, newCity: string, newZipCode: string) => {
// TypeScript отлично работает с вложенными объектами
userProfileState.address.street = newStreet;
userProfileState.address.city = newCity;
userProfileState.address.zipCode = newZipCode;
},
};
// --- Представьте, что это внутри React компонента ---
// const UserDisplay = () => {
// // useSnapshot возвращает Readonly<UserProfile>, предотвращая прямые мутации
// const snapshot = useSnapshot(userProfileState);
// // snapshot.name = 'Новое Имя'; // ОШИБКА КОМПИЛЯЦИИ! Снимок Readonly.
// return (
// <div>
// <h3>Профиль пользователя: {snapshot.name}</h3>
// <p>Email: {snapshot.email}</p>
// <p>Возраст: {snapshot.age}</p>
// <p>Адрес: {snapshot.address.street}, {snapshot.address.city}, {snapshot.address.zipCode}</p>
// <button onClick={() => userProfileActions.updateName('Яков Цукерман')}>
// Обновить имя
// </button>
// <button onClick={() => userProfileActions.updateAddress('Проспект Асинхронности, 42', 'JS-Сити', 'JS2 0JS')}>
// Обновить адрес
// </button>
// </div>
// );
// };
// console.log(<UserDisplay />); // Для демонстрации, в React это был бы рендер
console.log('Имя до:', userProfileState.name); // Яша Программист
userProfileActions.updateName('Яков Цукерман');
console.log('Имя после:', userProfileState.name); // Яков Цукерман

Valtio позволяет использовать геттеры в объекте состояния. Они будут реактивными, и TypeScript будет корректно выводить их тип. Это отличный способ для создания производного состояния.

import { proxy } from 'valtio';
interface Product {
id: string;
name: string;
price: number;
}
interface CartItem extends Product {
quantity: number;
}
interface ShopState {
products: Product[];
cart: CartItem[];
readonly totalItemsInCart: number; // Вычисляемое свойство
readonly cartTotalPrice: number; // Вычисляемое свойство
readonly hasItemsInCart: boolean; // Вычисляемое свойство
}
const shopState = proxy<ShopState>({
products: [
{ id: 'p1', name: 'Книга "TypeScript Deep Dive"', price: 49.99 },
{ id: 'p2', name: 'Кружка "Я люблю TS"', price: 15.00 },
{ id: 'p3', name: 'Футболка "Code is Art"', price: 29.99 },
],
cart: [],
// Геттеры для вычисляемых свойств.
// Valtio автоматически отслеживает зависимости (this.cart)
get totalItemsInCart() {
return this.cart.reduce((sum, item) => sum + item.quantity, 0);
},
get cartTotalPrice() {
return parseFloat(this.cart.reduce((sum, item) => sum + item.price * item.quantity, 0).toFixed(2));
},
get hasItemsInCart() {
return this.cart.length > 0;
}
});
const shopActions = {
addProductToCart: (productId: string) => {
const product = shopState.products.find(p => p.id === productId);
if (!product) return;
const existingCartItem = shopState.cart.find(item => item.id === productId);
if (existingCartItem) {
existingCartItem.quantity++;
} else {
shopState.cart.push({ ...product, quantity: 1 });
}
},
removeProductFromCart: (productId: string) => {
shopState.cart = shopState.cart.filter(item => item.id !== productId);
},
clearCart: () => {
shopState.cart = [];
},
};
shopActions.addProductToCart('p1');
shopActions.addProductToCart('p2');
shopActions.addProductToCart('p1'); // Добавляем еще одну книгу
console.log('Корзина:', JSON.stringify(shopState.cart, null, 2));
console.log('Всего товаров в корзине:', shopState.totalItemsInCart); // 3 (1+1+1)
console.log('Общая стоимость корзины:', shopState.cartTotalPrice); // (49.99 * 2) + (15.00 * 1) = 114.98
console.log('Есть ли товары в корзине?', shopState.hasItemsInCart); // true
shopActions.clearCart();
console.log('После очистки - всего товаров:', shopState.totalItemsInCart); // 0
console.log('После очистки - общая стоимость:', shopState.cartTotalPrice); // 0
console.log('После очистки - есть ли товары?', shopState.hasItemsInCart); // false

🎓 Продвинутые Техники: Разделение Состояния

Заголовок раздела «🎓 Продвинутые Техники: Разделение Состояния»

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

import { proxy } from 'valtio';
// --- Модуль Аутентификации ---
interface AuthState {
isAuthenticated: boolean;
user: { id: string; username: string; email: string } | null;
token: string | null;
}
const authState = proxy<AuthState>({
isAuthenticated: false,
user: null,
token: null,
});
export const authActions = {
login: (userData: Omit<AuthState['user'], 'id'>, token: string) => {
authState.isAuthenticated = true;
// В реальном приложении ID может прийти с сервера
authState.user = { id: 'usr-123', ...userData };
authState.token = token;
},
logout: () => {
authState.isAuthenticated = false;
authState.user = null;
authState.token = null;
},
};
// --- Модуль Настроек Пользователя ---
interface UserSettingsState {
theme: 'light' | 'dark';
notificationsEnabled: boolean;
language: 'en' | 'ru' | 'es';
}
const userSettingsState = proxy<UserSettingsState>({
theme: 'light',
notificationsEnabled: true,
language: 'en',
});
export const userSettingsActions = {
toggleTheme: () => {
userSettingsState.theme = userSettingsState.theme === 'light' ? 'dark' : 'light';
},
setLanguage: (lang: UserSettingsState['language']) => {
userSettingsState.language = lang;
},
};
// --- Использование модулей ---
console.log('До логина:', authState.isAuthenticated); // false
authActions.login({ username: 'yasha_dev', email: '[email protected]' }, 'my_jwt_token_123');
console.log('После логина:', authState.isAuthenticated, authState.user?.username); // true yasha_dev
console.log('Тема по умолчанию:', userSettingsState.theme); // light
userSettingsActions.toggleTheme();
console.log('Тема после переключения:', userSettingsState.theme); // dark
userSettingsActions.setLanguage('ru');
console.log('Язык:', userSettingsState.language); // ru
authActions.logout();
console.log('После логаута:', authState.isAuthenticated); // false
  1. Забыли useSnapshot в React-компонентах:
    • Ошибка: Ваш компонент не перерисовывается при изменении состояния прокси.
    • Решение: Всегда используйте const snapshot = useSnapshot(myProxyState); внутри функциональных компонентов React, чтобы получить реактивный снимок и подписаться на обновления.
    • Как помогает TypeScript: useSnapshot возвращает Readonly<T>, что является важным сигналом об иммутабельности.
  2. Попытка мутировать снимок (snapshot):
    • Ошибка: snapshot.prop = 'new value'; приведет к ошибке компиляции в TypeScript (в строгом режиме) или runtime ошибке, потому что снимок иммутабелен.
    • Решение: Изменяйте только оригинальный прокси-объект, например, через специально созданные действия: myProxyState.prop = 'new value'; или myActions.updateProp('new value');.
  3. Недостаточная типизация вложенных объектов:
    • Ошибка: При работе с глубоко вложенными структурами TypeScript может иногда “терять” точную информацию о типе, если вы не указали интерфейсы для всех уровней.
    • Решение: Определяйте четкие интерфейсы или типы для всех частей вашего состояния, даже для вложенных объектов, чтобы обеспечить полную безопасность типов.

Время применить полученные знания на практике! Создайте MDX-файл с решением каждой задачи.

  • Создайте состояние для магазина с двумя прокси:
    • productsState: массив объектов Productid, name, price). Заполните его несколькими продуктами.
    • cartState: массив объектов CartItemid продукта, name, price, quantity).
  • Реализуйте действия:
    • addProductToCart(productId: string): добавляет продукт в корзину. Если продукт уже есть, увеличивает количество.
    • removeProductFromCart(productId: string): полностью удаляет продукт из корзины.
    • decreaseProductQuantity(productId: string): уменьшает количество продукта на 1. Если количество становится 0, удаляет продукт.
    • clearCart(): очищает корзину.
  • В cartState добавьте вычисляемые свойства:
    • totalItemsInCart (общее количество штук всех товаров).
    • cartTotalPrice (общая стоимость всех товаров в корзине, округленная до двух знаков после запятой).
  • Обеспечьте полную типизацию всего состояния и действий. Продемонстрируйте работу действий, выводя состояние в консоль.
  • Создайте состояние userProfileState для хранения информации о пользователе:
    • id: string
    • username: string
    • email: string
    • settings: вложенный объект с theme: 'light' | 'dark', newsletterSubscription: boolean.
    • bio?: string (опциональное поле).
  • Реализуйте действия для обновления:
    • updateUsername(newUsername: string)
    • updateEmail(newEmail: string)
    • toggleTheme(): переключает тему между ‘light’ и ‘dark’.
    • toggleNewsletterSubscription()
    • updateBio(newBio: string)
    • clearBio(): удаляет поле bio или устанавливает его в undefined.
  • При обновлении theme используйте Union Type. Убедитесь, что все поля правильно типизированы, включая опциональные. Продемонстрируйте работу.
  • Создайте состояние taskListState с массивом объектов Task. Каждый Task должен иметь:
    • id: string
    • description: string
    • completed: boolean
    • priority: 'low' | 'medium' | 'high'
    • createdAt: Date
  • Реализуйте действия:
    • addTask(description: string, priority: Task['priority']): создает новую задачу.
    • toggleTaskCompletion(id: string)
    • removeTask(id: string)
    • setTaskPriority(id: string, newPriority: Task['priority'])
  • Добавьте вычисляемое свойство pendingTasksCount (количество незавершенных задач).
  • Продемонстрируйте работу всех действий и вычисляемого свойства.
  • Маленькие и сфокусированные прокси: Вместо одного огромного прокси для всего приложения, создавайте несколько маленьких, логически связанных прокси (как мы сделали с authState и userSettingsState). Это улучшает модульность, облегчает тестирование и делает типизацию более управляемой.
  • Действия как “фасады”: Всегда инкапсулируйте логику изменения состояния в функциях (действиях). Это делает ваш код более предсказуемым, тестируемым и легким для понимания. Компоненты должны вызывать действия, а не напрямую мутировать прокси-объект.
  • Иммутабельность для снимков: Помните, что useSnapshot возвращает иммутабельный объект. Любые изменения должны происходить только через оригинальный прокси. TypeScript, благодаря Readonly<T>, активно поможет вам это контролировать.
  • Явные типы для ясности: Хотя Valtio часто может вывести типы, явное указание интерфейсов для вашего состояния (особенно для сложных вложенных структур) значительно улучшает читаемость и предсказуемость кода, а также помогает TypeScript дать вам более точные подсказки и ошибки.
  • Производительность: Для очень больших массивов или объектов, которые часто обновляются, будьте внимательны к тому, какие части вы делаете реактивными. Valtio обычно очень быстр, но понимание, как Proxy отслеживает изменения, всегда полезно.