Управление состоянием
Зачем нужно
Состояние (state) — это данные, от которых зависит интерфейс. В SPA состояние хранится на клиенте: введённый текст, список товаров, авторизован ли пользователь. Когда приложение растёт, управление состоянием становится главной архитектурной задачей: нужно понимать, где хранить данные, как их обновлять и как доставлять в нужные компоненты.
Где используется
- Формы — значения полей, валидация, состояние отправки
- Списки — данные с сервера, фильтрация, пагинация
- Авторизация — токен, данные пользователя, права доступа
- UI-состояние — открытые модалки, активные табы, тема оформления
- Корзина — выбранные товары, количество, стоимость
Проблема состояния в SPA
Компонент A (Header)
├─ показывает имя пользователя
├─ показывает количество товаров в корзине
│
Компонент B (Sidebar)
├─ показывает меню для авторизованных
│
Компонент C (ProductList)
├─ добавляет товар в корзину
│ → должен обновить Header (счётчик)
│ → должен обновить CartPage (список)
│
Компонент D (CartPage)
├─ показывает товары
├─ удаляет товар
→ должен обновить Header (счётчик)
Проблема: данные нужны в разных местах, и изменение в одном месте должно отразиться в других.
Паттерны управления состоянием
1. Локальное состояние (Component State)
Данные живут внутри одного компонента:
// Простейшее локальное состояние
class Counter {
constructor(element) {
this.element = element;
this.state = { count: 0 };
this.render;
}
setState(newState) {
this.state = { ...this.state, ...newState };
this.render; // Перерисовка при изменении
}
increment {
this.setState({ count: this.state.count + 1 });
}
render {
this.element.innerHTML = `
<span>${this.state.count}</span>
<button id="inc">+</button>
`;
this.element.querySelector('#inc')
.addEventListener('click', () => this.increment);
}
}
new Counter(document.getElementById('app'));
Когда использовать: данные нужны только внутри одного компонента (открыто/закрыто модальное окно, значение поля ввода).
2. Props drilling (передача через пропсы)
Данные передаются от родителя к потомкам через параметры:
// Родительский компонент владеет состоянием
class App {
constructor {
this.state = { user: null, cart: };
}
render {
// Передаём состояние в дочерние компоненты
const header = new Header({
user: this.state.user,
cartCount: this.state.cart.length,
});
const productList = new ProductList({
onAddToCart: (product) => this.addToCart(product),
});
}
addToCart(product) {
this.state.cart = [...this.state.cart, product];
this.render; // Перерисовка всего дерева
}
}
Проблема prop drilling: если компоненту E нужны данные от A, они пробрасываются через B, C, D — которым эти данные не нужны.
3. Глобальное хранилище (Store)
Централизованное хранилище, доступное из любого компонента:
// === Паттерн Store (упрощённый Redux) ===
class Store {
constructor(reducer, initialState) {
this.state = initialState;
this.reducer = reducer;
this.listeners = ;
}
// Получить текущее состояние
getState {
return this.state;
}
// Отправить действие (action)
dispatch(action) {
// Reducer создаёт НОВОЕ состояние на основе действия
this.state = this.reducer(this.state, action);
// Уведомляем подписчиков
this.listeners.forEach(fn => fn(this.state));
}
// Подписаться на изменения
subscribe(listener) {
this.listeners.push(listener);
// Возвращаем функцию отписки
return => {
this.listeners = this.listeners.filter(fn => fn !== listener);
};
}
}
// === Reducer — чистая функция ===
// (текущее состояние, действие) → новое состояние
function appReducer(state, action) {
switch (action.type) {
case 'ADD_TO_CART':
return {
...state,
cart: [...state.cart, action.payload],
};
case 'REMOVE_FROM_CART':
return {
...state,
cart: state.cart.filter(item => item.id !== action.payload),
};
case 'SET_USER':
return {
...state,
user: action.payload,
};
default:
return state;
}
}
// === Использование ===
const store = new Store(appReducer, {
user: null,
cart: ,
});
// Подписка — Header обновляется при изменении
store.subscribe((state) => {
document.getElementById('cart-count').textContent = state.cart.length;
document.getElementById('user-name').textContent =
state.user?.name || 'Гость';
});
// Действия из любого места приложения
store.dispatch({
type: 'ADD_TO_CART',
payload: { id: 1, name: 'Ноутбук', price: 50000 },
});
store.dispatch({
type: 'SET_USER',
payload: { name: 'Антон', email: 'anton@mail.ru' },
});
4. Паттерн Observer (Event Bus)
Простейший механизм подписки/публикации:
class EventBus {
constructor {
this.events = {};
}
on(event, callback) {
if (!this.events[event]) this.events[event] = ;
this.events[event].push(callback);
}
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(cb => cb(data));
}
}
// Глобальная шина событий
const bus = new EventBus();
// Header подписывается
bus.on('cart:updated', (cart) => {
document.getElementById('cart-count').textContent = cart.length;
});
// ProductList публикует
function addToCart(product) {
cart.push(product);
bus.emit('cart:updated', cart);
}
Иммутабельность (Immutability)
Ключевой принцип управления состоянием — никогда не мутировать данные напрямую:
// ❌ ПЛОХО — мутация
const state = { user: 'Антон', cart: [1, 2, 3] };
state.cart.push(4); // Изменили оригинал
// ✅ ХОРОШО — создаём новый объект
const newState = {
...state,
cart: [...state.cart, 4],
};
// === Иммутабельные операции ===
// Добавить в массив
const added = [...array, newItem];
// Удалить из массива
const removed = array.filter(item => item.id !== targetId);
// Обновить элемент массива
const updated = array.map(item =>
item.id === targetId ? { ...item, name: 'Новое имя' } : item
);
// Обновить вложенный объект
const newState = {
...state,
user: {
...state.user,
settings: {
...state.user.settings,
theme: 'dark',
},
},
};
Зачем иммутабельность:
- Простое сравнение:
oldState !== newState— значит были изменения - Предсказуемость: функция-reducer возвращает новый объект, не трогая старый
- Отладка: можно хранить историю всех состояний (time-travel debugging)
- Безопасность: защита от случайных мутаций в других частях кода
Сравнение подходов
| Подход | Сложность | Масштаб | Отладка | Когда |
|---|---|---|---|---|
| Локальное состояние | Низкая | Один компонент | Простая | Формы, UI-переключатели |
| Prop drilling | Низкая | 2-3 уровня | Средняя | Маленькие приложения |
| Event Bus | Средняя | Любой | Сложная | Слабосвязанные компоненты |
| Store (Redux-like) | Высокая | Любой | Лучшая | Большие приложения |
Частые ошибки
- Мутация состояния —
state.items.push(item)вместо[...state.items, item]— компоненты не обновятся - Всё в глобальный store — локальное UI-состояние (открыта модалка?) не нужно хранить глобально
- Нет нормализации — вложенные данные дублируются, обновлять больно
- Подписка без отписки — при удалении компонента слушатель остаётся → утечка памяти
- Асинхронность в reducer — reducer должен быть чистой функцией, async-логика выносится наружу
- Слишком глубокая вложенность —
state.a.b.c.d.e = x— лучше нормализовать
Практика
- Реализовать класс
Storeс методамиgetState,dispatch,subscribe - Написать reducer для TODO-приложения (добавить, удалить, переключить статус)
- Подключить 3 независимых компонента к одному store
- Добавить middleware для логирования каждого action
- Реализовать undo/redo через хранение истории состояний
Связанные темы
- Что такое SPA — почему состояние — проблема именно в SPA
- Компонентный подход — компоненты как потребители состояния
- Virtual DOM — как изменение состояния вызывает перерисовку
- MVC — MVC как подход к организации данных
Ресурсы
- Redux — Fundamentals
- Patterns.dev — State Management
- MDN — Proxy (реактивность)