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

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