Олег Марков
Состояние приложения - как устроен state-management и зачем он нужен
Введение
Управление состоянием приложения (state-management) становится ключевой задачей, как только интерфейс перестает быть набором независимых страниц и превращается в живое, интерактивное приложение. Вы начинаете обрабатывать формы, запросы к серверу, уведомления, авторизацию, корзину, фильтры, меню. Каждый из этих элементов хранит данные, меняет их и как-то реагирует на изменения.
Состояние (state) — это не только данные с сервера. Это все, что влияет на то, как сейчас выглядит и работает интерфейс, начиная от выбранной вкладки и заканчивая списком товаров, подгруженных по API.
Если не продумать, как вы управляете этим состоянием, код быстро превращается в набор «заплаток»: события обрабатываются в разных местах, одно изменение ломает другое, а отладка превращается в поиск «кто в этот раз поменял эти данные».
В этой статье я разберу, что такое состояние приложения, какие его виды удобнее выделять, чем отличаются разные подходы к state-management и когда вообще стоит подключать «тяжелую артиллерию» вроде Redux или других библиотек. В примерах я буду опираться в первую очередь на фронтенд (SPA, React), но общие идеи подходят и для других платформ.
Что такое состояние приложения
Виды состояния
Чтобы не запутаться, полезно сначала разложить состояние на несколько категорий. Давайте сделаем это по простой и практичной схеме.
UI-состояние (view state)
Это состояние, которое описывает, как сейчас выглядит интерфейс:
- открыты ли модальные окна
- какая вкладка выбрана
- какой элемент выделен
- какой фильтр включен в данный момент
- состояние пагинации (активная страница)
Пример (на псевдокоде в стиле React):
// Состояние, управляющее только внешним видом интерфейса
const [isModalOpen, setIsModalOpen] = useState(false) // Открыт ли модал
const [activeTab, setActiveTab] = useState('info') // Какая вкладка выбрана
Это состояние обычно «живёт» рядом с компонентом, который его использует, и никуда далеко не распространяется.
Доменное (бизнес-)состояние
Это «смысловые» данные, с которыми работает бизнес-логика:
- текущий пользователь и его права
- список товаров
- корзина
- список заказов
- баланс на счете
Именно доменное состояние чаще всего нужно показывать в разных местах приложения и хранить дольше, чем одно посещение страницы.
Пример:
// Данные, которые приходят по API и имеют бизнес-смысл
const [user, setUser] = useState(null) // Авторизованный пользователь
const [cartItems, setCartItems] = useState([]) // Товары в корзине
Состояние процесса (async / network state)
Это все, что связано с текущими операциями:
- идет ли загрузка данных
- были ли ошибки
- в очереди ли запрос
- успешно ли завершилась операция
Пример:
// Состояние, описывающее процесс взаимодействия с API
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
Здесь важно не путать «результат» (например, список пользователей) и «процесс» (идет ли загрузка этого списка).
Кэш и серверное состояние
Отдельно часто выделяют серверное состояние (server state):
- данные, которые приходят с бэкенда
- данные, которые можно повторно запросить
- данные, которые могут быть устаревшими и их нужно обновлять
Особенность: такое состояние обычно не нужно хранить «навсегда», его проще заново загрузить с сервера, чем пытаться поддерживать синхронность между клиентом и сервером. Именно для этого появились библиотеки типа React Query, SWR и им подобные.
Почему важно разделять состояние
Если не разделять виды состояния, можно столкнуться с типичными проблемами:
- UI-состояние и доменные данные смешаны в одном месте
- непонятно, что можно просто перезагрузить с сервера, а что хранится только на клиенте
- сложнее тестировать бизнес-логику: она растворена внутри компонентов
Когда вы разделяете:
- UI-состояние живет ближе к компонентам
- доменное состояние можно вынести в отдельный слой (store, сервисы)
- серверное состояние можно доверить специальным библиотекам
Теперь давайте разберем основные подходы к управлению состоянием.
Локальное состояние в компонентах
Когда локального состояния достаточно
Локальное состояние — это переменные, которые «живут» внутри конкретного компонента или отдельного модуля. В React это useState, useReducer, в Vue — data / ref / reactive, в Svelte — обычные переменные компонента.
Локальное состояние отлично подходит, когда:
- данные используются только в одном или паре ближайших компонентов
- состояние описывает исключительно внешний вид (UI-состояние)
- нет сложных зависимостей между этими данными и другим кодом
Пример локального состояния на React
Смотрите, я покажу вам простой пример управления состоянием для формы логина.
import { useState } from 'react'
function LoginForm() {
// Локальное состояние полей формы
const [email, setEmail] = useState('') // Значение поля email
const [password, setPassword] = useState('') // Значение поля password
// Локальное состояние процесса
const [isSubmitting, setIsSubmitting] = useState(false) // Идет ли отправка
const [error, setError] = useState(null) // Ошибка при отправке
const handleSubmit = async (event) => {
event.preventDefault() // Отменяем стандартное поведение формы
setIsSubmitting(true) // Устанавливаем флаг "отправка началась"
setError(null) // Сбрасываем прошлую ошибку
try {
// Здесь мы могли бы отправить запрос на сервер
await fakeLogin(email, password) // fakeLogin - условная функция логина
} catch (e) {
setError('Ошибка авторизации') // Сохраняем текст ошибки в состоянии
} finally {
setIsSubmitting(false) // Сбрасываем флаг "отправка идет"
}
}
return (
<form onSubmit={handleSubmit}>
{/* Поле email управляется через локальное состояние */}
<input
value={email}
onChange={e => setEmail(e.target.value)} // Обновляем состояние при вводе
placeholder="Email"
/>
{/* Поле password также использует локальное состояние */}
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)} // Обновляем при вводе
placeholder="Пароль"
/>
{/* Сообщение об ошибке зависит от состояния error */}
{error && <div style={{ color: 'red' }}>{error}</div>}
{/* Кнопка блокируется, когда идет отправка */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Входим...' : 'Войти'}
</button>
</form>
)
}
// Условная функция, имитирующая логин
async function fakeLogin(email, password) {
// Здесь можно представить запрос к серверу
return new Promise((resolve, reject) => {
setTimeout(() => {
// В демонстрационных целях всегда делаем "успех"
resolve()
}, 1000)
})
}
Как видите, все состояние здесь локально и не нужно никуда его «поднимать». Это самый простой и понятный вариант.
Проблемы локального состояния
Чем больше вы строите приложение на одном только локальном состоянии, тем чаще появляются ситуации, когда:
- те же данные нужны в нескольких несвязанных компонентах (например, профиль пользователя — в шапке и на странице аккаунта)
- при изменении данных нужно обновить много разных частей интерфейса
- для изменения одного значения приходится пробрасывать callbacks через несколько уровней компонентов
Вот здесь и появляется вопрос о глобальном состоянии.
Глобальное состояние и «подъем состояния»
Подъем состояния (lifting state up)
Подъем состояния — это когда вы выносите общие данные выше по дереву компонентов, чтобы несколько дочерних компонентов могли их использовать.
Давайте разберемся на примере: есть два компонента — список фильтров и список товаров. Фильтры меняют состояние, а товары должны на него реагировать.
function ProductsPage() {
// Состояние фильтров на уровне родителя
const [filters, setFilters] = useState({
search: '', // Строка поиска
category: '' // Выбранная категория
})
return (
<div>
{/* Передаем состояние и функцию обновления в Filters */}
<Filters filters={filters} onChange={setFilters} />
{/* В Products передаем только нужные значения */}
<Products filters={filters} />
</div>
)
}
function Filters({ filters, onChange }) {
// Обработчик изменения строки поиска
const handleSearchChange = (event) => {
const value = event.target.value
// Обновляем общий объект фильтров
onChange({ ...filters, search: value })
}
return (
<input
placeholder="Поиск"
value={filters.search}
onChange={handleSearchChange}
/>
)
}
function Products({ filters }) {
// Здесь могли бы фильтровать товары по filters.search и filters.category
// Пример условного использования
// const filteredProducts = products.filter(p => p.name.includes(filters.search))
return (
<div>
{/* Здесь показываем список товаров с учетом фильтров */}
Список товаров c фильтром: {filters.search}
</div>
)
}
Такой подход хорошо работает, пока:
- уровней вложенности немного
- состояние используют в основном дочерние компоненты
Как только вы начинаете тянуть состояние «через несколько уровней вниз» к компонентам, которые его используют, компоненты посредники начинают «надуваться» и теряется читаемость.
Контекст как простой глобальный стор (на примере React)
Когда подъем состояния усложняется, часто переходят к механизму контекста (Context API). Он позволяет объявить «глобальное» для поддерева состояние и использовать его без явной передачи через пропсы.
Покажу вам, как это выглядит.
import { createContext, useContext, useState } from 'react'
// Создаем контекст для авторизации
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
// Состояние текущего пользователя
const [user, setUser] = useState(null)
// Функция логина
const login = (userData) => {
setUser(userData) // Сохраняем данные пользователя
}
// Функция логаута
const logout = () => {
setUser(null) // Очищаем состояние пользователя
}
// Объект, который будет доступен в любых дочерних компонентах
const value = { user, login, logout }
return (
// Оборачиваем все приложение в провайдер
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
// Хук для удобного использования контекста
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth должен использоваться внутри AuthProvider')
}
return context
}
Теперь вы можете использовать состояние авторизации в любом месте приложения:
function Header() {
const { user, logout } = useAuth() // Получаем состояние и функции из контекста
return (
<header>
{user ? (
<>
<span>Привет, {user.name}</span>
<button onClick={logout}>Выйти</button>
</>
) : (
<span>Гость</span>
)}
</header>
)
}
Контекст — это уже простейший state-management. Но у него есть ограничения:
- сложно контролировать, какие компоненты будут перерисовываться
- логика может разрастаться внутри провайдеров
- для сложных сценариев начинают дублироваться решения, которые уже реализованы в специализированных библиотеках
Паттерн централизованного хранилища (store)
Идея «единого источника правды»
Когда приложение становится достаточно сложным, появляется идея: выделить слой, который будет отвечать за состояние приложения и его изменения. Такой слой часто называют store (хранилище).
Базовые принципы централизованного хранилища:
- у вас есть единый источник правды — объект или набор объектов, в которых лежит текущее состояние
- изменение состояния происходит только через определенные функции (actions, reducers, методы)
- интерфейс «подписывается» на изменения и обновляется, когда нужные части состояния изменяются
Это не привязано к конкретной библиотеке. Подобный подход можно реализовать самостоятельно и на чистом JavaScript.
Давайте посмотрим простую, «ручную» реализацию.
Пример простого store на чистом JavaScript
Здесь я размещаю пример, чтобы вам было проще понять, как работает подписка на изменения.
// Простое хранилище состояния
function createStore(initialState) {
let state = initialState // Текущее состояние
const listeners = new Set() // Набор подписчиков
// Функция получения состояния
const getState = () => state
// Функция подписки на изменения
const subscribe = (listener) => {
listeners.add(listener) // Добавляем слушателя
return () => listeners.delete(listener) // Возвращаем функцию отписки
}
// Функция обновления состояния
const setState = (updater) => {
// updater может быть объектом или функцией
const nextState =
typeof updater === 'function' ? updater(state) : updater
// Обновляем ссылку на состояние
state = { ...state, ...nextState }
// Уведомляем всех подписчиков
listeners.forEach((listener) => listener(state))
}
return { getState, setState, subscribe }
}
// Использование
const store = createStore({ count: 0 }) // Задаем начальное состояние
// Подписываемся на изменения
const unsubscribe = store.subscribe((state) => {
console.log('Новое состояние', state)
})
// Меняем состояние
store.setState({ count: 1 }) // Лог: Новое состояние { count: 1 }
store.setState((prev) => ({ count: prev.count + 1 })) // Лог: { count: 2 }
// Отписываемся, когда больше не нужно следить
unsubscribe()
Этот подход уже дает вам:
- единый объект состояния
- контролируемые способы обновления
- подписку на изменения
Библиотеки вроде Redux, Zustand, MobX, Pinia, Vuex развивают эти идеи дальше: добавляют удобный синтаксис, интеграцию с фреймворками, инструменты отладки.
Иммутабельность состояния
Почему важна иммутабельность
Во многих подходах к state-management используется принцип иммутабельности: вы не меняете существующий объект состояния «на месте», а создаете новый объект на основе старого.
Это нужно для:
- предсказуемости: вы знаете, когда именно состояние меняется
- простоты отката изменений (time-travel, undo/redo)
- оптимизации производительности через сравнение ссылок (shallow compare)
- упрощения отладки, так как каждое изменение — это новый объект
Пример «плохого» и «хорошего» обновления массива:
// Плохо - мутируем существующий массив
const state = { items: [1, 2, 3] }
state.items.push(4) // Изменяем существующий массив
// Хорошо - создаем новый массив
const state2 = { items: [1, 2, 3] }
const nextState = {
...state2, // Копируем старое состояние
items: [...state2.items, 4] // Создаем новый массив с добавленным элементом
}
Иммутабельность — один из ключевых принципов в Redux и похожих библиотеках.
Redux как пример детерминированного state-management
Основные концепции Redux
Redux — это популярная библиотека для управления состоянием в JavaScript-приложениях, основанная на нескольких идеях:
- одно глобальное хранилище (store) для всего состояния
- состояние только для чтения (read-only)
- единственный способ изменить состояние — диспатчинг действий (actions)
- чистые функции reducers описывают, как состояние меняется при разных действиях
Теперь вы увидите, как это выглядит в коде.
Структура Redux
- Store — объект, который хранит состояние.
- Action — обычный объект, который описывает, «что произошло».
- Reducer — чистая функция (state, action) => newState.
Пример Redux-слайса с использованием Redux Toolkit
Сейчас для работы с Redux чаще всего используют Redux Toolkit (RTK), который снижает «боулерплейт» и упрощает работу с иммутабельностью.
import { configureStore, createSlice } from '@reduxjs/toolkit'
// Создаем слайс состояния для счетчика
const counterSlice = createSlice({
name: 'counter', // Имя слайса
initialState: { value: 0 }, // Начальное состояние
reducers: {
increment(state) {
// Здесь мы как будто мутируем state
// Но под капотом RTK использует Immer и делает иммутабельное обновление
state.value += 1
},
decrement(state) {
state.value -= 1
},
addByAmount(state, action) {
// action.payload - значение, переданное при вызове экшена
state.value += action.payload
}
}
})
// Получаем редьюсер и экшены
export const { increment, decrement, addByAmount } = counterSlice.actions
// Создаем store
export const store = configureStore({
reducer: {
counter: counterSlice.reducer // Регистрируем редьюсер в корневом сторе
}
})
Использование этого состояния в React-компонентах через react-redux:
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, addByAmount } from './counterSlice'
function Counter() {
// Берем значение из состояния
const value = useSelector(state => state.counter.value) // Достаем счетчик
// Получаем функцию dispatch
const dispatch = useDispatch()
return (
<div>
<div>Значение: {value}</div>
{/* Вызываем экшены через dispatch */}
<button onClick={() => dispatch(decrement())}>-</button>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(addByAmount(5))}>+5</button>
</div>
)
}
Обратите внимание, как этот фрагмент кода решает задачу:
- компонент не знает деталей внутренней реализации хранилища
- вся логика изменения состояния находится в слайсе
- легко тестировать редьюсеры отдельно от компонентов
Когда имеет смысл использовать Redux
Redux оправдан, когда:
- у вас большое приложение с множеством независимых модулей
- есть сложные сценарии обновления данных (например, несколько разных операций над одними и теми же сущностями)
- важно иметь детализированную историю изменений состояния (для отладки, аудита)
- нужно предсказуемое поведение и строгие правила изменения состояния
Если у вас маленькое приложение или простая форма, Redux может быть избыточен.
Альтернативные библиотеки и подходы
Zustand — минималистичный store
Zustand предлагает простой API, который ближе к примеру с createStore, который мы уже рассматривали. Часто выбирают, когда нужно небольшое, но гибкое глобальное хранилище.
Пример Zustand-store:
import create from 'zustand'
// Создаем хук useStore
export const useStore = create((set, get) => ({
count: 0, // Начальное состояние
// Метод увеличения
increment: () => set(state => ({ count: state.count + 1 })),
// Метод уменьшения
decrement: () => set(state => ({ count: state.count - 1 })),
// Метод, использующий текущее состояние через get
incrementIfOdd: () => {
const { count } = get() // Получаем текущее значение
if (count % 2 === 1) {
set({ count: count + 1 })
}
}
}))
Использование в компоненте:
function Counter() {
// Деструктурируем нужные части состояния и методы
const { count, increment, decrement } = useStore()
return (
<div>
<div>Значение: {count}</div>
<button onClick={decrement}>-</button>
<button onClick={increment}>+</button>
</div>
)
}
Zustand не навязывает иммутабельность, структуру и паттерн «actions/reducers», но при этом дает:
- простую подписку на определенные части состояния
- легкую интеграцию с React через хуки
MobX — реактивный подход
MobX строится вокруг идеи «обсервабельных» данных (observable) и реактивных вычислений. Вы описываете:
- какие данные «наблюдаемые»
- какие вычисления зависят от этих данных
MobX самостоятельно отслеживает зависимости и обновляет только нужные части интерфейса.
Плюсы:
- меньше кода для сложных связей между данными
- естественная интеграция с ООП-стилем (классы, методы)
Минус — сложнее строго контролировать поток данных, чем в Redux.
React Query / SWR — управление серверным состоянием
Отдельная категория инструментов — библиотеки, которые берут на себя работу с серверным состоянием: загрузка, кэширование, инвалидация, повторные запросы, обработка ошибок.
React Query пример:
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
function UsersList() {
// useQuery сам управляет isLoading, error и данными
const { data, isLoading, error } = useQuery({
queryKey: ['users'], // Ключ кэша
queryFn: () => axios.get('/api/users').then(res => res.data) // Функция запроса
})
if (isLoading) return <div>Загрузка...</div>
if (error) return <div>Ошибка</div>
return (
<ul>
{/* data - это массив пользователей */}
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
Чем хорош такой подход:
- вы не храните серверные данные руками в Redux или другом сторе
- библиотека решает задачи повторной загрузки, фонового обновления и т.п.
- сосредотачиваетесь на бизнес-логике, а не на кэширующей инфраструктуре
Как выбирать подход к state-management
Несколько практичных правил
Давайте посмотрим, как можно подойти к выбору инструмента и архитектуры.
1. Начинайте с локального состояния
Если вы пишете новый модуль или страницу:
- сначала используйте локальное состояние (useState, useReducer, data, ref)
- разделяйте UI-состояние, доменное состояние и состояние процессов
- не вводите глобальные концепции, пока это не станет реальной проблемой
2. Поднимайте состояние по мере необходимости
Когда вы понимаете, что:
- данные нужны нескольким компонентам
- связи между компонентами становятся плотнее
— поднимайте состояние выше по дереву, создавайте кастомные хуки, используйте контекст.
3. Выделяйте доменный слой
Если бизнес-логика становится сложной:
- выделяйте доменные сущности (пользователь, заказ, товар)
- создавайте сервисы или отдельные модули для работы с ними
- не смешивайте детали API, формат данных и UI-логику в компонентах
Это можно сделать и без Redux — просто вынести логику в отдельные модули.
4. Подключайте специализированные библиотеки при росте сложности
Подумайте о Redux, Zustand, MobX и других инструментах, если:
- состояние нужно переиспользовать во многих частях приложения
- появляется сложная синхронизация клиент–сервер
- нужно четко контролировать поток данных, логировать действия, строить отчеты
Для серверного состояния чаще выгоднее использовать React Query / SWR, чем тащить все в один «глобальный стор».
Типичные ошибки при state-management
Вот несколько ситуаций, которые часто приводят к проблемам:
- попытка все состояние держать в одном огромном глобальном сторе
- смешение серверного состояния и состояния UI в одном хранилище
- отсутствие правил, кто и как может изменять состояние
- хранение в состоянии того, что можно вычислить «на лету»
Пример лишнего состояния:
// Плохо - мы храним derived state, которое легко вычисляется
const [fullName, setFullName] = useState('')
const firstName = 'Иван'
const lastName = 'Иванов'
// fullName можно просто вычислить как `${firstName} ${lastName}`
Лучше хранить только то, что нельзя однозначно вычислить по другим данным.
Теперь давайте перейдем к краткому резюме.
Управление состоянием приложения — это, по сути, управление сложностью. Чем больше взаимосвязей между данными и интерфейсом, тем важнее становится четкая архитектура state-management.
Ключевые идеи, которые стоит удерживать:
- состояние — это все, что влияет на текущее отображение интерфейса, а не только данные с сервера
- полезно разграничивать UI-состояние, доменное состояние и состояние процессов
- начинайте с простого: локальное состояние, подъем, контекст
- не смешивайте разные типы состояния без необходимости
- используйте специализированные библиотеки, когда размер и сложность приложения этого требуют
- старайтесь держать бизнес-логику рядом с доменными сущностями, а не внутри компонентов
Если вы будете постепенно усложнять архитектуру по мере необходимости, а не «на всякий случай», вы сохраните код понятным и управляемым.
Частозадаваемые технические вопросы по теме и ответы
Как организовать откат состояния (undo/redo) в приложении
Обычно используют один из двух подходов:
История состояний:
- храните массив previousStates
- при каждом изменении состояния добавляйте в историю предыдущую версию
- для undo берите последнее состояние из истории и делайте его текущим
- для redo храните отдельный стек «вперед»
История действий:
- храните список actions, которые привели к текущему состоянию
- храните также inverseActions, которые отменяют действие
- при undo выполняйте inverseAction для последнего action
Практически проще начать с хранения предыдущих состояний, но следите за объемом памяти (обрезайте историю по длине).
Как лучше хранить сущности - в виде массива или словаря по id
Для коллекций, к которым часто обращаются по id, удобно использовать нормализованный вид:
- entitiesById — объект, где ключи это id, а значения это сущности
- allIds — массив всех id (для сохранения порядка)
Пример структуры:
{
usersById: {
'1': { id: '1', name: 'Аня' },
'2': { id: '2', name: 'Петя' }
},
allUserIds: ['1', '2']
}
Так проще обновлять, удалять и искать сущности.
Как синхронизировать состояние между вкладками браузера
Используйте один из вариантов:
localStorage + событие storage:
- при изменении состояния записывайте его (или нужные части) в localStorage
- в других вкладках подписывайтесь на событие window.addEventListener('storage', handler)
- при изменении обновляйте локальное состояние
BroadcastChannel:
- создайте канал new BroadcastChannel('app')
- отправляйте сообщения о важных изменениях
- во всех вкладках подписывайтесь на канал и обновляйте состояние
Не отправляйте через эти механизмы «все состояние целиком» без необходимости — выбирайте только нужные части.
Как избежать лишних перерисовок при глобальном состоянии
Основные приемы:
- делите состояние на независимые слайсы (user, cart, ui)
- в React используйте селекторы, которые выбирают только нужные части состояния
- мемоизируйте вычисляемые данные (reselect в Redux, useMemo в React)
- в контексте разбивайте контексты по ответственности, чтобы одно изменение не перерисовывало всю поддеревню
Главная идея — чем меньше данных компонент читает из глобального хранилища, тем реже он будет перерисовываться.
Как тестировать логику изменения состояния отдельно от UI
Вынесите бизнес-логику в чистые функции или редьюсеры:
- функция принимает текущее состояние и действие
- возвращает новое состояние
- не использует внешние эффекты (запросы, таймеры и т.д.)
Дальше пишите unit-тесты:
- задаете начальное состояние
- вызываете функцию с нужным действием
- проверяете, что вернулось ожидаемое состояние
Так вы сможете менять UI, не трогая тесты логики, и наоборот.