Управление состоянием в React: держим все просто
Управление состоянием в React: простые правила server state vs client state, признаки роста сложности и практические шаги для сгенерированных приложений.

Что такое состояние и почему оно быстро усложняется
Состояние в React - это любые данные, которые могут меняться и из-за которых интерфейс должен перерисоваться. Пока этих данных мало, все кажется простым: положили в useState, показали на экране, по клику изменили.
Сложность начинается, когда одни и те же данные появляются в нескольких местах, меняются разными способами и должны оставаться согласованными. Тогда управление состоянием превращается не в работу с экраном, а в постоянные догадки: где лежит «истина», кто последний обновлял, что делать при ошибке и почему вчера все работало.
Под «состоянием» обычно смешивают сразу несколько разных вещей: серверные данные (списки, профили, настройки), локальный UI (модалки, вкладки, сортировка), ввод пользователя (черновик формы), производные значения (например, «всего товаров» из списка) и временные флаги (загрузка, ошибка, «сохранено»).
В сгенерированных React приложениях хаос часто приходит быстрее. Причина обычно не в генерации, а в темпе: функции добавляются одна за другой, и каждый раз проще «поставить еще один стейт», чем остановиться и договориться о правилах. В vibe-coding сценариях это заметно особенно хорошо: итерации быстрые, а цена лишней сложности растет незаметно. Если вы используете TakProsto, этот эффект легко поймать на ранних версиях, пока проект еще маленький.
Цель простого подхода - меньше типов состояния и меньше мест, где оно живет. Если данные с сервера, пусть они живут как серверные данные. Если это чистый UI, пусть остается локально. Самый дорогой вариант - когда одно и то же хранится и в компоненте, и в глобальном сторе, и «на всякий случай» в каком-то кэше.
Ниже - «держим скучно» подход: четкие правила, что считать server state, что считать client state, и признаки того, что сложность вот-вот взорвется.
Два вида состояния: server state и client state
Больше всего ошибок появляется в одном месте: когда путают источник правды. Если удерживать это правило в голове, лишняя сложность часто просто не возникает.
Server state - это данные, которые живут на сервере и меняются по его правилам: список товаров, профиль пользователя, статус заказа, права доступа, результаты поиска. Даже если вы получили эти данные через API и показали на экране, источник правды все равно сервер. Данные могут обновиться в другом месте, устареть, поменяться из-за прав или бизнес-правил.
Client state - это локальное состояние интерфейса: открыта ли модалка, какая вкладка выбрана, что введено в поле, какая строка подсвечена, какие фильтры набраны до нажатия «Применить». Это состояние живет рядом с компонентами и обычно не нужно серверу.
Проблемы начинаются, когда эти типы смешивают. Типичный сценарий: серверные данные кладут в локальный стор, потом их «чуть-чуть» правят для UI и забывают синхронизировать. В итоге появляются призраки: на одном экране одно, на другом другое, а после обновления страницы все «внезапно» откатывается.
Простое правило: серверные данные не дублируем в локальных сторах без явной причины. Чаще всего достаточно кэша библиотеки запросов и локальных UI-флагов рядом с компонентом.
Быстрая проверка:
- Если значение должно пережить обновление страницы и совпадать у всех пользователей - это server state.
- Если значение нужно только для текущего экрана и исчезает при уходе - это client state.
- Если вы ловите себя на «сохраним копию ответа API в стор, чтобы было удобно», остановитесь и проверьте, не создаете ли вы вторую «правду».
Пример: есть список задач и панель деталей. Список и детали приходят с сервера (server state). А выбранная задача, режим «редактирование» и текст в поле ввода до сохранения - это client state. По кнопке «Сохранить» вы отправляете изменения на сервер, а не «правите» локальную копию задачи в сторе.
Правила «держим скучно»: куда класть каждое состояние
Главный принцип: сначала понять, откуда берутся данные и кто их меняет. Если отвечать на эти вопросы честно, половина сложных сторов не понадобится.
Начните с одного вопроса: эти данные пришли с сервера или родились в интерфейсе? У них разные правила жизни. Server state может устаревать, конфликтовать между вкладками и пользователями, подгружаться повторно. UI state обычно короткоживущий и понятный: открыть модалку, подсветить ошибку, сохранить ввод.
Набор «скучных» правил, который держит код в форме даже при быстром росте:
- Если нужно просто показать данные с сервера, живите запросами и кэшем, а не глобальным стором.
- Server state храните в библиотеке для запросов и кэширования (например, React Query или SWR). В компоненты прокидывайте данные и статусы загрузки.
- UI state держите рядом с тем местом, где он используется:
useStateв компоненте или в небольшом контейнере рядом. - Если состояние нужно нескольким компонентам, поднимайте его только до ближайшего общего родителя. Не выше «на всякий случай».
- Если состояние вроде бы нужно «везде», сначала убедитесь, что это правда, и что это не server state, замаскированный под «глобальное».
Небольшой пример. Есть список заказов, фильтр и попап с деталями. Список, детали и статус оплаты - server state: запросы, кэш, обновления. А выбранный фильтр, открыто ли окно деталей, текст в поиске - UI state в компонентах. Так один глобальный стор не начинает отвечать одновременно за сеть, форму и модалки.
Практика для сгенерированного интерфейса: пройдитесь по переменным состояния и подпишите «сервер» или «UI». Если метка не очевидна, это часто сигнал, что модель данных уже начинает путаться.
Как организовать server state без лишней ручной логики
Server state - это данные, которые приходят по сети: списки, карточки, профиль, настройки. Полезно относиться к ним как к кэшу, а не как к «истине» в клиенте. Тогда меньше ручных флагов и меньше связей между экранами.
Самый надежный путь - взять библиотеку для server state (React Query или SWR) и перестать хранить «загрузка/ошибка/успех» в собственных useState. Вместо десятков флагов вы опираетесь на статус запроса и данные.
Обычно достаточно договориться о нескольких вещах:
- Loading берется из
isLoading/isFetching, а не из собственногоloading. - Error берется из
error, и у вас есть понятное место, где он показывается. - Empty - это не флаг, а условие: данных нет или массив пустой.
- Данные на экране - это
query.data(с нормальными дефолтами), без промежуточных копий. - Повторная загрузка - это
refetchили автоматическое обновление по ключу.
После мутаций (создать, удалить, изменить) выберите один подход и придерживайтесь его:
-
Инвалидировать запросы и дать библиотеке перезагрузить данные. Это скучно, но почти всегда правильно.
-
Обновить кэш вручную, если изменения маленькие и понятные (например, переименовали элемент в списке).
Оптимистические обновления используйте там, где ошибка не ломает смысл экрана. «Лайк» или небольшое переименование обычно переживают откат нормально. А вот создание сущностей, где сервер решает id, права и финальные поля, часто проще делать без оптимизма.
Чтобы список и экран детали не расходились, держите единый формат данных (одинаковый shape объекта) и единые ключи кэша. Например: список использует ключ ["items"], а деталь - ["item", id]. После сохранения детали вы либо инвалидируете оба ключа, либо обновляете кэш так, чтобы и список, и деталь увидели одинаковое значение.
Клиентское состояние: UI и ввод пользователя без перегруза
Client state - это все, что нужно интерфейсу прямо сейчас: выбранная вкладка, текст в поле ввода, открыта ли модалка. Чаще всего ломаются не данные, а правила: куда что класть и как это потом сбрасывать.
Для поиска, фильтров, сортировки и пагинации решите одно: пользователь должен уметь поделиться этим состоянием как ссылкой или нет. Если да - храните в URL, и обновление страницы не будет сюрпризом.
Удобная опора:
- В URL: поисковая строка, фильтры каталога, сортировка, номер страницы (то, что нужно сохранить и переслать).
- В памяти (
useState): временный ввод, подсказки, раскрытые блоки, выбранная строка. - На сервере: результаты списка и детали (их не надо дублировать в client state).
Формы усложняются, когда смешивают черновик и «истину». Держите черновик локально, а после успешной отправки обновляйте server state и сбрасывайте поля.
Простая схема для формы: черновик значений, ошибки валидации, флаг отправки. Не добавляйте четвертое «почти то же самое» (например, отдельный объект для предпросмотра), пока нет реальной причины.
Модалки, табы, аккордеоны и тосты лучше всего живут рядом с компонентом, который их показывает. Если модалка открывается только из одной кнопки на странице, не тяните ее в глобальный стор.
Контекст уместен, когда настройка реально кросс-страничная и меняется редко: тема, язык интерфейса, компактный режим таблиц, роль пользователя (если UI зависит от нее).
Сигналы, что сложность вот-вот взорвется
Если состояние начинает «расползаться», это обычно видно по мелким симптомам. В сгенерированном приложении их легко пропустить, потому что код появляется быстро.
Первый тревожный знак - один и тот же флаг или значение меняется из разных мест. Сегодня isLoading выставляется в компоненте, завтра в хуке, послезавтра в обработчике модалки. В итоге никто не понимает, кто «владелец» состояния.
Второй знак - «супер-объект» состояния, куда складывают все подряд: фильтры, данные, ошибки, пагинацию, выбранный элемент, статус модалки, временный ввод. Пока полей пять, терпимо. Когда их двадцать, любое изменение ломает что-то рядом.
Третий знак - цепочки useEffect, которые чинят друг друга: один «подтягивает» данные, второй «приводит» форму к данным, третий «сбрасывает» ошибку, четвертый пересчитывает derived значения. Обычно это означает, что смешали server state и client state и пытаются склеить их вручную.
Четвертый знак - постоянные рассинхроны: на сервере уже сохранено, а UI показывает старое. Или наоборот: UI обновили оптимистично, но откат выглядит как отдельный проект.
Если хотите короткий стоп-лист, вот что почти всегда стоит разрулить сразу:
- Одни и те же данные хранятся в двух местах (в компоненте и в сторе, в сторе и в кэше запросов).
- Появились ручные «инвалидаторы» и костыли, чтобы заставить экран обновиться.
- Состояние приходится протаскивать через 3-4 уровня пропсов, чтобы «просто показать» кусок UI.
- Любая правка требует трогать сразу несколько файлов, иначе все ломается.
- При отладке вы чаще смотрите на порядок эффектов, чем на бизнес-логику.
Пошаговый план упрощения уже запутанного состояния
Если в проекте уже накопились «кусочки правды» в разных местах, начните с карты. Цель простая: каждый факт должен иметь один источник, а компоненты должны в основном отображать и вызывать действия.
Шаг 1-2: карта источников и чистка дублей
Возьмите один проблемный экран и выпишите, где реально живут данные. Обычно это комбинация сервера, URL, формы и локального UI.
- Сервер: списки, карточки, права, статусы, то, что должно переживать перезагрузку.
- URL: выбранный id, фильтры, пагинация, вкладка, то, чем важно делиться ссылкой.
- Форма: текущий ввод до сохранения, ошибки валидации, dirty-состояние.
- UI: открыта ли модалка, активный таб, временные переключатели.
- Временное: загрузка, ошибки, успехи, которые можно вывести из запроса.
Потом ищите дубли. Классический пример: «текущий пользователь» хранится и в глобальном сторе, и в компоненте, и в кэше запросов. Оставьте один источник и удалите остальные копии. Это страшно первые полчаса, но почти всегда быстро окупается.
Шаг 3-4: единые ключи кэша и слой API
Когда фактов стало меньше, договоритесь о правилах кэша: как называются ключи, что считается свежим, когда данные обновляются. Это уменьшает ручную синхронизацию.
Дальше вынесите работу с API из компонентов. Компоненту не должны быть важны детали эндпоинтов, заголовков и преобразований данных.
Практичный минимум:
- Функции API: чистые вызовы (
getUser,updateUser). - Хуки для запросов:
useUser(id)с единым ключом. - Хуки для мутаций:
useUpdateUser()и понятное поведение после успеха.
Частые ошибки и ловушки
Самые болезненные проблемы редко начинаются с «плохого кода». Обычно это пара удобных решений «на потом», которые незаметно превращают данные в кашу.
Ошибка 1: тянуть серверные данные в глобальный стор «на всякий случай»
Списки, карточки и профили, которые приходят с API, часто кладут в общий стор, чтобы «везде было доступно». Через месяц уже неясно, кто и когда их обновляет и почему на одном экране данные новые, а на другом старые.
Если это server state, пусть его жизненным циклом управляет инструмент для запросов (кэш, повторная загрузка, инвалидирование), а не самодельный слой синхронизации.
Ошибка 2: плодить копии одного и того же
Сценарий знакомый: есть «оригинал с сервера», «копия для UI» и «копия для формы». Потом добавляется «копия для модалки» и «копия для предпросмотра». Как только появляется 2-3 копии, вы начинаете чинить рассинхрон вместо фич.
Лучше держать один источник правды. Для формы храните только то, что реально меняет пользователь (черновик), и четкое правило: когда черновик создается и когда сбрасывается.
Ошибка 3: один большой эффект на все случаи
Когда useEffect и его зависимости пытаются одновременно загрузить данные, открыть модалку, выставить выбранный элемент и сбросить форму, появляется магия. Любое новое условие ломает старое, а баги зависят от порядка рендеров.
Разделяйте причины: загрузка данных отдельно, реакция на пользовательские действия отдельно, синхронизация формы с выбранным объектом отдельно.
Ошибка 4: флаги «грязности» в десяти местах
Если isDirty живет в компоненте формы, в сторе и еще в родителе, он всегда будет «немного неправдой». Обычно достаточно одного правила: «форма грязная, если черновик отличается от последней сохраненной версии».
Пример: вы редактируете карточку товара. Оставьте серверные данные как есть, а в форме храните только draft. При сохранении: отправили мутацию, обновили кэш, сбросили draft. При «Отмена»: просто сбросили draft, не трогая кэш и глобальные сторы.
Пример на жизненной задаче: список, детали и редактирование
Представьте экран: слева список заявок, справа карточка выбранной заявки и модалка редактирования. Это типичная точка, где состояние начинает пухнуть.
«Скучный» вариант здесь простой: все, что пришло с сервера, остается server state (и живет в кэше), а UI и ввод пользователя остаются client state.
Разложение по полкам:
- Фильтры, сортировка, текущая страница: client state (часто лучше в URL).
- Список заявок: server state (запрос по ключу, который включает фильтры и пагинацию).
- Выбранная заявка (id): client state (URL-параметр или локально, но один источник).
- Детали заявки: server state (запрос по id или чтение из кэша).
- Поля формы в модалке: client state (локальный черновик до «Сохранить»).
После сохранения главное - не делать ручную синхронизацию в трех местах. Держите одно правило: сервер - источник правды, а кэш обновляется предсказуемо.
Чаще всего хватает последовательности: отправили mutation, обновили кэш детали, инвалидировали список (если изменения влияют на него), закрыли модалку и сбросили локальное состояние формы.
Чтобы список и карточка не расходились, не храните «копию заявки» отдельно в состоянии списка и отдельно в карточке. Пусть обе части читают данные из одного кэша, а выбранность определяется только id.
Короткий чеклист перед тем, как добавлять новое состояние
Перед тем как завести новый useState или еще один контекст, остановитесь на минуту. Чаще всего ломает не «сложная бизнес-логика», а лишние копии данных и флаги, которые начинают противоречить друг другу.
5 быстрых вопросов
- У этого факта есть один источник правды? Выберите один: сервер (API), URL (параметры, фильтры), форма (черновик ввода), UI (модалка, вкладка).
- Это точно не server state? Если данные пришли с API и могут устареть, не храните их параллельно в кэше запроса и локальном стейте без явной причины.
- Понятны ли четыре состояния экрана: загрузка, ошибка, пусто, успех? Если для «загрузки» уже нужно 3 флага и 2 проверки, вы накапливаете лишнее состояние.
- Что будет после мутации (создали, обновили, удалили)? Нужен понятный план: обновить кэш, перезапросить данные или локально поправить одну сущность. И делать это одинаково по всему приложению.
- Сколько «склеек» вы добавляете? Флаги вроде
isSyncing,shouldRefetch,hasLocalOverrideи эффекты, которые «подклеивают» UI, обычно означают, что пора убрать промежуточные состояния.
Если после чеклиста вы все равно хотите добавить новое состояние, запишите одним предложением: «зачем оно нужно и почему нельзя вычислить его из уже существующих данных». Если предложение не получается простым, состояние, скорее всего, лишнее.
Что делать дальше и как закрепить подход в проекте
Чтобы «держим скучно» работало не неделю, а год, превратите его в пару коротких правил и закрепите их в коде и привычках команды.
Первый практичный шаг - выделить отдельный слой для запросов и мутаций. Он становится особенно полезен, когда эндпоинтов больше пары, появляются повторы, нужны общие настройки кэша и единая обработка ошибок. В этот момент проще договориться: все, что пришло с сервера, живет в инструментах для server state (например, React Query или SWR), а не в локальных useState.
С глобальным стором лучше быть жадным: класть туда только то, что реально должно быть общим и переживать смену экранов. Обычно это авторизация и текущий пользователь, тема и язык, фиче-флаги, редкие «глобальные» UI-сигналы вроде общего тоста, черновики, которые должны жить между страницами, и выбранный контекст приложения (например, активная организация).
Чтобы код (в том числе сгенерированный) не «разъезжался», зафиксируйте несколько договоренностей: где лежат хуки для запросов и мутаций и как они называются, что считается запретным (например, ручное хранение server state в сторе), как оформляются загрузка/ошибка/пустые состояния, и где проходит граница формы (черновик) и серверных данных.
Если вы делаете приложение через TakProsto, удобно закрепить эти правила прямо перед генерацией новых экранов: в planning mode описать, что относится к server state, что к client state, и какой минимум допустим в глобальном сторе. А саму платформу проще всего найти по названию хоста: takprosto.ai.
FAQ
Как быстро понять, server state это или client state?
Если это данные, которые приходят из API и могут измениться без вашего ведома (другой пользователь, другая вкладка, серверные правила) — это server state. Если это то, что нужно только для текущего интерфейса (модалка, вкладка, выделенная строка, черновик ввода) — это client state.
Быстрый тест: должно ли значение переживать обновление страницы и быть одинаковым для всех? Тогда это сервер.
Нужно ли сохранять ответ API в глобальный стор?
Держите серверные данные в кэше библиотеки запросов (например, React Query/SWR) и читайте их прямо из query.data.
Не делайте «копию ответа API в сторе ради удобства». Обычно это создает второй источник правды и рассинхроны между экранами.
Что делать после мутации: обновлять кэш вручную или инвалидировать запрос?
По умолчанию — инвалидировать запросы и дать библиотеке заново подтянуть данные. Это проще и надежнее.
Ручное обновление кэша имеет смысл, когда правка маленькая и понятная (например, переименование элемента в списке).
Почему вредно иметь несколько копий одних и тех же данных?
Когда одно и то же «фактологическое» значение живет в нескольких местах: в компоненте, в сторе и еще где-то в кэше.
Почти всегда выгоднее оставить один источник правды: серверные данные — в кэше запросов, а в форме — только черновик пользователя.
Как правильно организовать состояние формы, чтобы не было каши?
Держите черновик формы локально (client state), а серверные данные не «правьте» напрямую.
Паттерн:
- открыть форму → создать
draftиз серверных данных; - сохранить → отправить mutation, обновить/инвалидировать кэш, сбросить
draft; - отменить → просто сбросить
draft.
Что лучше хранить в URL, а что в useState?
Если пользователь должен уметь обновить страницу или отправить ссылку и получить тот же экран — храните это в URL (поиск, фильтры, сортировка, страница, выбранный id).
Если это временно и не важно за пределами экрана — оставляйте в useState.
Какие признаки показывают, что состояние вот-вот выйдет из-под контроля?
Типичный набор: isLoading выставляется в трех местах, «супер-объект» состояния разрастается, появляются цепочки useEffect, которые «чинят» друг друга.
Если вы чаще следите за порядком эффектов, чем за логикой экрана — это признак, что смешали server state и client state.
Когда поднимать состояние вверх по дереву компонентов?
Поднимайте состояние только до ближайшего общего родителя, которому реально нужно управлять этим состоянием.
Если хочется поднять «на всякий случай» — остановитесь и проверьте: возможно, это server state (его вообще не надо поднимать, достаточно общего кэша запросов).
Что вообще стоит хранить в глобальном сторе?
Оставляйте в глобальном сторе только то, что действительно кросс-страничное и не является серверными данными: авторизация, тема/язык, фиче-флаги, редкие глобальные UI-сигналы (например, общий тост), выбранный контекст приложения (например, активная организация).
Списки/карточки/профили с API обычно не должны жить в глобальном сторе.
С чего начать, если в проекте уже запутанное состояние?
Сделайте один проход по всем useState/контекстам/сторам на экране и подпишите каждому: сервер / URL / форма / UI.
Дальше:
- удалите дубли (оставьте один источник правды);
- вынесите API-вызовы в отдельные функции и хуки;
- договоритесь об единых ключах кэша и правилах «что делаем после мутации».