Управление состоянием в React просто: отделяйте серверное состояние от клиентского, следуйте нескольким правилам и ловите ранние признаки роста сложности.

State — это любые данные, которые могут меняться во время работы приложения. Это то, что вы видите (модал открыт), что вы редактируете (черновик формы) и данные, которые вы запрашиваете (список проектов). Проблема в том, что всё это называется просто state, хотя ведёт себя по-разному.
Большинство запутанных приложений ломаются одинаково: слишком много типов состояния смешиваются в одном месте. Компонент начинает держать серверные данные, UI-флаги, черновики форм и производные значения, а затем пытается синхронизировать их эффектами. Через какое-то время вы не можете ответить на простые вопросы: "откуда это значение?" или "что его обновляет?" без поиска по нескольким файлам.
Сгенерированные React-приложения дрейфуют в такую ситуацию быстрее, потому что легко принять первый рабочий вариант. Добавили экран, скопировали паттерн, заплатали баг другим useEffect — и в результате у вас уже две истины. Если генератор или команда в середине пути меняют подход (здесь локальное состояние, там глобальный store), кодовая база собирает паттерны вместо того, чтобы строиться сверху на одном.
Цель скучная: меньше видов состояния и меньше мест, где их искать. Когда для серверных данных и для чисто UI-состояния есть по одному очевидному месту, баги становятся меньше, а изменения перестают казаться рискованными.
"Держите скучным" значит следовать нескольким правилам:
Конкретный пример: если список пользователей приходит с бэкенда, рассматривайте его как серверное состояние и запрашивайте там, где он используется. Если selectedUserId нужен только для панели деталей, держите его как локальное состояние рядом с этой панелью. Смешивание этих двух — как начинается сложность.
Большинство проблем со state начинается с одной путаницы: серверные данные принимают за UI-состояние. Отделите их рано, и управление состоянием останется спокойным, даже если приложение будет расти.
Серверное состояние принадлежит бэкенду: пользователи, заказы, задачи, права, цены, feature flags. Оно может измениться без участия вашего приложения (другая вкладка обновила его, админ изменил, запустилась задача, данные устарели). Поскольку оно разделяется и может меняться, нужны fetch, кэширование, refetch и обработка ошибок.
Клиентское состояние — это то, что важно только для текущего UI: какой модал открыт, какая вкладка выбрана, переключатель фильтра, порядок сортировки, свернутая боковая панель, черновик поискового запроса. Если вы закроете вкладку, потеря этого состояния обычно допустима.
Быстрый тест: "Могу ли я обновить страницу и восстановить это с сервера?"
Есть ещё производное состояние: значение, которое можно вычислить из других значений, поэтому его не хранят. Фильтрованные списки, итоги, isFormValid и «показывать ли пустое состояние» обычно относятся сюда.
Пример: вы запрашиваете список проектов (серверное). Выбранный фильтр и флаг открытия диалога "Новый проект" — это клиентское состояние. Видимый список после фильтрации — производное. Если вы храните видимый список отдельно, он рассинхронизируется и вы будете искать, почему он устарел.
Такое разделение помогает, когда инструмент вроде Koder.ai быстро генерирует экраны: держите данные бэкенда в одном слое fetch, UI-выборы близко к компонентам и избегайте хранения вычисляемых значений.
Состояние становится болезненным, когда у одного куска данных появляется два владельца. Быстрый способ упростить — решить, кто за что отвечает, и придерживаться этого.
Пример: вы запрашиваете список пользователей и показываете детали выбранного. Обычная ошибка — хранить в state полностью выбранного пользователя. Храните selectedUserId. Список остаётся в серверном кэше. Просмотр деталей ищет пользователя по ID, поэтому refetch автоматически обновит UI без лишнего синхрона.
В сгенерированных React-приложениях легко принять "полезное" сгенерированное состояние, которое дублирует серверные данные. Если вы видите код вроде fetch -> setState -> редактирование -> refetch, остановитесь. Это часто знак, что в браузере вы собираете вторую базу данных.
Серверное состояние — это всё, что живёт на бэкенде: списки, страницы деталей, результаты поиска, права, счётчики. Скучный подход — выбрать один инструмент для него и придерживаться.
Для многих React-приложений достаточно TanStack Query.
Цель проста: компоненты просят данные, показывают состояния загрузки и ошибки и не заботятся, сколько реальных fetch-запросов происходит под капотом. Это важно для сгенерированных приложений, потому что мелкие несоответствия быстро множатся при добавлении новых экранов.
Обращайтесь с query keys как с системой наименований, а не как с деталями, о которых забывают. Держите их последовательными: стабильные массивные ключи, включайте только входные параметры, которые меняют результат (фильтры, страница, сортировка) и предпочитайте несколько предсказуемых форм вместо множества одноразовых ключей. Многие команды выносят построение ключей в небольшие хелперы, чтобы все экраны использовали одни и те же правила.
Для записей используйте mutations с явной обработкой успеха. Мутация должна отвечать на два вопроса: что поменялось и что UI должен сделать дальше?
Пример: вы создаёте новую задачу. При успехе либо инвалидируйте запрос списка задач (чтобы он перезагрузился), либо выполните целенаправленное обновление кэша (добавьте задачу в кэшированный список). Выберите один подход для каждой фичи и держитесь его.
Если вас тянет добавить refetch в нескольких местах "на всякий случай", выберите один скучный ход:
Клиентское состояние — то, что браузер хранит: флаг открытия боковой панели, выбранная строка, текст фильтра, черновик перед сохранением. Держите его близко к месту использования — и оно обычно остаётся управляемым.
Начинайте с простого: useState в ближайшем компоненте. Когда вы генерируете экраны (например с помощью Koder.ai), может захотеться запихнуть всё в глобальный store "на всякий случай". Так появляется store, который никто толком не понимает.
Поднимайте состояние вверх только когда можете назвать проблему совместного использования.
Пример: таблица с панелью деталей может хранить selectedRowId в компоненте таблицы. Если ещё и тулбар в другой части страницы нуждается в этом значении, поднимите его в компонент страницы. Если отдельный маршрут (например bulk edit) тоже должен иметь доступ одновременно, тогда имеет смысл небольшой store.
Если вы используете store (Zustand или похожий), держите его сфокусированным на одной задаче. Храните "что" (выбранные ID, фильтры), а не "результаты" (отсортированные списки), которые можно вывести из других данных.
Когда store начинает разрастаться, спросите себя: это всё ещё одна фича? Если честный ответ "вроде того", разбейте его сейчас, до того как следующая фича превратит его в шар состояния, которого страшно коснуться.
Баги в формах часто происходят от смешения трёх вещей: того, что пользователь печатает, того, что сохранено на сервере, и того, что показывает UI.
Для скучного управления состоянием рассматривайте форму как клиентское состояние до отправки. Серверные данные — это последняя сохранённая версия. Форма — это черновик. Не редактируйте серверный объект на месте. Скопируйте значения в draft state, дайте пользователю менять их, затем отправьте и при успехе refetch или обновите кэш.
Решите заранее, что должно сохраняться при навигации. Это одно решение предотвращает много сюрпризов. Например, inline режим редактирования и открытые выпадающие списки обычно должны сбрасываться, тогда как длинный черновик мастера или несохранённое сообщение может сохраняться. Персистить при перезагрузке стоит только если пользователи явно этого ждут (например корзина/чекаут).
Держите правила валидации в одном месте. Если вы разбросаете правила по инпутам, хэндлерам submit и хелперам, получите несогласованные ошибки. Предпочтите одну схему (или одну функцию validate()), а UI пусть решает, когда показывать ошибки (on change, on blur или on submit).
Пример: вы генерируете экран Edit Profile в Koder.ai. Загрузите сохранённый профиль как серверное состояние. Создайте draft для полей формы. Показывайте "unsaved changes", сравнивая draft и saved. Если пользователь отменил — отбрасывайте draft и показывайте серверную версию. Если сохранил — отправьте draft, затем замените сохранённую версию ответом сервера.
По мере роста сгенерированного React-приложения часто одно и то же данные оказываются в трёх местах: state компонента, глобальный store и кэш. Решение обычно не в новой библиотеке, а в выборе одного дома для каждого куска состояния.
Поток очистки, который работает в большинстве приложений:
filteredUsers, если его можно вычислить из users + filter. Предпочитайте selectedUserId вместо дублированного selectedUser.Пример: CRUD-приложение, сгенерированное Koder.ai, часто стартует с useEffect fetch и копии списка в глобальном store. После централизации серверного состояния список берут из одного запроса, а "обновление" становится инвалидацией, а не ручным синхронизированием.
Для именований держите их последовательными и скучными:
users.list, users.detail(id)ui.isCreateModalOpen, filters.userSearchopenCreateModal(), setUserSearch(value)users.create, users.update, users.deleteЦель — один источник правды на каждую сущность и чёткие границы между серверным и клиентским состоянием.
Проблемы со state начинаются мелко, а потом вы меняете поле, и три части UI расходятся во мнениях о "реальном" значении.
Самый явный признак — дублирование данных: один и тот же пользователь или корзина живёт в компоненте, глобальном store и запросном кэше. Каждая копия обновляется в разное время, и вы добавляете код только чтобы держать их в равновесии.
Другой признак — код синхронизации: эффекты, которые толкают состояние туда и обратно. Паттерны вроде "когда query меняется — обновить store" и "когда store меняется — refetch" могут работать, пока не найдётся крайний случай, приводящий к устаревшим значениям или циклам.
Несколько явных тревожных сигналов:
needsRefresh, didInit, isSaving, которые никто не удаляет.Пример: вы генерируете dashboard в Koder.ai и добавляете модал Edit Profile. Если профиль хранится в query cache, копируется в глобальный store и дублируется в локальном состоянии формы, у вас теперь три источника правды. Как только вы добавите фоновый refetch или optimistic updates, рассинхронизации проявятся.
Когда вы видите эти признаки, скучный ход — выбрать одного владельца для каждого куска данных и удалить зеркала.
Хранение вещей "на всякий случай" — один из быстрых способов сделать состояние болезненным, особенно в сгенерированных приложениях.
Копирование ответов API в глобальный store — распространённая ловушка. Если данные приходят с сервера (списки, детали, профиль), не копируйте их по умолчанию в клиентский store. Выберите один дом для серверных данных (обычно кэш запросов). Клиентский store используйте для UI-only значений, о которых сервер не знает.
Хранение производных значений — ещё одна ловушка. Счётчики, фильтрованные списки, итоги, canSubmit и isEmpty обычно вычисляют из входных данных. Если производительность реально проблема, замемоизируйте позже, но не начинайте с хранения результата, который может устареть.
Один большой мега-store для всего (auth, модалы, тосты, фильтры, черновики, onboarding flags) превращается в склад. Разбивайте по границам фич. Если состояние используется только одним экраном, держите его локальным.
Context хорош для стабильных значений (theme, current user id, locale). Для часто меняющихся значений он может вызывать широкие перерендеры. Используйте Context для проводки, а component state (или маленький store) для быстро меняющихся UI-значений.
И наконец, избегайте непоследовательных имён. Почти-одинаковые query keys и поля store создают тонкие дублирования. Выберите простой стандарт и следуйте ему.
Start by labeling every piece of state as server, client (UI), or derived.
isValid).Once you label them, make sure each item has one obvious owner (query cache, local component state, URL, or a small store).
Use this quick test: “Could I refresh the page and rebuild this from the server?”
Example: a project list is server state; the selected row ID is client state.
Because it creates two sources of truth.
If you fetch users and then copy them into useState or a global store, you now have to keep them in sync during:
Default rule: and only create local state for UI-only concerns or drafts.
Store derived values only when you truly can’t compute them cheaply.
Usually you should compute from existing inputs:
visibleUsers = users.filter(...)total = items.reduce(...)canSubmit = isValid && !isSavingIf performance becomes real (measured), prefer or better data structures before introducing more stored state that can go stale.
Default: use a server-state tool (commonly TanStack Query) so components can just “ask for data” and handle loading/error states.
Practical basics:
Keep it local until you can name a real sharing need.
Promotion rule:
This keeps your global store from becoming a dumping ground for random UI flags.
Store IDs and small flags, not full server objects.
Example:
selectedUserIdselectedUser (copied object)Then render details by looking up the user from the cached list/detail query. This makes background refetches and updates behave correctly without extra syncing effects.
Treat the form as a draft (client state) until you submit.
A practical pattern:
This avoids accidentally editing server data “in place” and fighting refetches.
Common red flags:
needsRefresh, didInit, isSaving that keep accumulating.Generated screens can drift into mixed patterns fast. A simple safeguard is to standardize ownership:
If you’re using Koder.ai, use Planning Mode to decide ownership before generating new screens, and rely on snapshots/rollback when experimenting with state changes so you can back out cleanly if a pattern goes wrong.
useMemoAvoid sprinkling refetch() calls everywhere “just in case.”
The fix is usually not a new library—it’s deleting mirrors and picking one owner per value.