17 апр. 2026 г.·7 мин чтения

Владение состоянием на фронтенде: простая карта до разрастания UI

Управление состоянием во frontend начинается с простой карты. Узнайте, где должны жить серверное, локальное, форма и URL‑состояние, прежде чем компоненты разрастутся.

Владение состоянием на фронтенде: простая карта до разрастания UI

Почему состояние быстро становится грязным

Проблемы со состоянием редко начинаются с одной большой ошибки. Они чаще происходят из небольшого упрощения, которое кажется безобидным. Компонент хранит копию полученных данных, чтобы отсортировать их. Другой компонент дублирует те же данные, чтобы показать бейдж. Затем форма нуждается в значениях по умолчанию, и кто‑то снова копирует запись.

Через неделю UI всё ещё в основном работает. Именно поэтому проблему трудно заметить. В приложении теперь три или четыре версии одной и той же правды, и каждая обновляется по своему расписанию.

Вот тут и важно владение состоянием. Если никто не решит, кто «владеет» фрагментом данных, каждый компонент пытается помочь. Это звучит практично, но создаёт дрейф. Одна часть читает свежие серверные данные, другая — локальное состояние, отражающее состояние из пяти кликов назад, а третья — значения формы, которые пользователь ещё не сохранил.

Проблема усугубляется, когда разные типы состояния накладываются друг на друга. Фильтр может жить в URL, но кто‑то также держит его в состоянии компонента ради удобства. Форма может стартовать с серверных данных, затем хранить собственные правки, а родитель при этом отслеживает изменения для панели сводки. Небольшое изменение теперь вызывает обновления во многих местах.

Вы обычно замечаете это по странным симптомам:

  • Кнопка сброса очищает форму, но не список.
  • Количество в бейдже меняется, а таблица — нет.
  • Обновление страницы фиксит баг, а потом он возвращается.
  • В двух вкладках одно и то же экран показывает разные результаты.

Эти баги кажутся случайными, потому что устаревшая копия часто спрятана. Неправильное значение не выглядит очевидно неверным. Оно может быть на один рендер позади, или неверным только после рефетча, или только тогда, когда пользователь редактирует поле и одновременно меняет фильтр в той же сессии.

Команды часто винят React, стор или библиотеку фетчинга данных. Большую часть времени причина проще. У приложения нет карты, где должно жить состояние, поэтому данные протекают через границы. Серверное состояние смешивается с локальным UI‑состоянием. Правки формы смешиваются с сохранёнными записями. Параметры URL дублируются в значениях компонентов без явной причины.

Когда это происходит, даже маленькие фичи становятся дорогими. Небольшое изменение продукта может затронуть пять компонентов, два хука и стор, который никогда не должен был владеть этими данными. Кодовая база не выглядит сломанной. Просто с каждой неделей работать становится сложнее.

Четыре типа состояния

Большинство frontend‑багов начинается, когда разные типы данных оказываются в одном месте. Простое правило помогает: сортируйте состояние по тому, откуда оно берётся и как долго должно жить.

Серверное состояние и локальное состояние

Серверное состояние — это данные, которые ваш UI читает, но не владеет ими по‑настоящему. Оно приходит из API, базы данных или другого бэкенд‑источника и может измениться без участия текущего экрана. Сюда попадают список продуктов, детали аккаунта, остатки на складе или статус заказа.

Поскольку эти данные живут вне компонента, их обычно нужно запрашивать, кешировать, показывать состояние загрузки и обновлять. Проблемы начинаются, когда компонент копирует серверные данные в локальное состояние без явной причины. Так вкрадываются устаревшие значения, и разные части экрана начинают показывать разные ответы.

Локальное состояние гораздо меньше по объёму. Оно управляет одним взаимодействием или одним экраном: открытая модалка, выбранная карточка, переключатель сайдбара или развернутая строка. Если только одна часть UI заботится об этом — держите состояние рядом с этой частью. Глобальный стор обычно избыточен для простого поведения на экране.

Состояние формы и состояние URL

Состояние формы — это черновое состояние. Оно хранит значения, которые человек вводит, ошибки валидации, пометку «touched» и флаги dirty. Если пользователь редактирует название продукта, но ещё не сохранил, этот черновик принадлежит форме, а не серверу. Смешивание этих двух часто вызывает раздражающие баги, например сброс полей после рефетча.

Состояние URL предназначено для опций, которые должны пережить перезагрузку и быть легко делимыми. Фильтры, вкладки, порядок сортировки, номера страниц и поисковые запросы относятся сюда. Если кто‑то скопирует адрес страницы и пришлёт его коллеге, вид должен открыться с тем же контекстом. Если эти настройки живут только в памяти, контекст теряется.

Простой продуктовый экран наглядно показывает разделение. Полученные продукты — это серверное состояние. Панель фильтров, открытая в данный момент, — локальное состояние. Поля диалога редактирования — состояние формы. Выбранная категория и номер страницы — состояние URL.

Каждому типу нужно своё место. Серверное состояние принадлежит слою фетчинга данных. Локальное состояние — рядом с компонентом. Состояние формы — в самой форме. Состояние URL — в адресной строке. Это простое разделение делает код гораздо более предсказуемым.

Как решить, кто владеет данными

Большинство багов со состоянием начинается с одной небольшой ошибки: данные живут не там, где нужно, а потом другой компонент копирует их «на время». Через неделю обе копии расходятся. Владение состоянием становится гораздо проще, если перед тем, как писать дерево компонентов, задавать пару прямых вопросов.

Начните с происхождения. Кто создаёт значение первым? Если бэкенд его создаёт, UI обычно не должен притворяться, что владеет им. Список продуктов из API — это серверное состояние. Если пользователь вводит значение в инпут и оно существует только во время редактирования, форма владеет им. Если выпадающее меню открыто две секунды и никому больше не нужно — держите это состояние локально.

Затем спросите, кто может его изменить. Если многие экраны могут обновлять одни и те же данные, нужен один источник, а не несколько зеркал. Профиль пользователя, который обновляют в модалке, на странице настроек и в меню хедера, не должен храниться в трёх локальных копиях. Назначьте одного владельца и пусть остальные читают у него.

Третий вопрос экономит много правок позже: должно ли значение переживать перезагрузку? Если да, локальное состояние компонента обычно не подходит. Поисковые фильтры, порядок сортировки, активная вкладка и пагинация часто принадлежат URL, потому что пользователи ожидают, что при перезагрузке или сохранении ссылки вид восстановится.

Простой тест работает хорошо:

  • Если сервер — источник правды, держите это как серверное состояние.
  • Если пользователь редактирует черновик, храните его в форме до сохранения.
  • Если значение влияет только на одну мелочь UI, держите его локально.
  • Если страницу нужно восстановить или поделиться ею, поместите значение в URL.
  • Если двум местам действительно нужен один и тот же живой вариант — поднимите его один раз и остановитесь.

Последний пункт важен. Многие команды поднимают состояние слишком рано. Они выносят чекбокс, состояние диалога или временный выбор в родителя или глобальный стор, потому что так кажется безопаснее. Обычно это делает код сложнее. Держите состояние как можно ближе к компоненту, который реально им владеет. Поднимайте его только когда две части одновременно нуждаются в одном источнике.

Хорошее правило простое: если удаление компонента делает состояние бессмысленным, этот компонент, вероятно, им и владеет. Если состояние всё ещё важно после исчезновения компонента — владелец должен быть выше.

Нарисуйте карту до того, как писать код

Откройте пустой документ или whiteboard и запишите все фрагменты данных, видимые на экране. Используйте простые названия: продукты, выбранная категория, текст поиска, номер страницы, черновик заголовка, ошибка при сохранении, модалка открыта, порядок сортировки. Это займёт пять минут и часто предотвратит дни исправлений.

Затем пометьте каждую позицию владельцем. Большинство путаницы начинается, когда одно значение тихо живёт в двух местах. Ничего сложного не нужно. Небольшого списка достаточно: серверное состояние для бэкенд‑данных, локальное состояние для деталей UI, состояние формы для несохранённых вводов и состояние URL для фильтров, пагинации и опций просмотра, которые должны переживать перезагрузку и работу кнопки «назад».

После этого напишите рядом по две заметки: откуда оно начинается и кто может его изменить. «Список продуктов» начинается на сервере, и его может изменить рефетч или мутация. «Текст в поиске» может стартовать из URL, затем пользователь его меняет. «Черновик формы» инициализируется серверными данными однажды, затем форма владеет им до сохранения или сброса.

Эта вторая заметка важнее, чем многие думают. Если две части UI могут менять одно и то же значение, нужен один источник правды и понятный путь обновления. Если пропустить этот шаг, дублирование состояния появляется быстро: фильтр скопировали в локальное состояние, то же значение зеркалируют в URL, а форма инициализируется устаревшим кешем.

Будьте строги в вопросе, что должно пережить перезагрузку и навигацию назад. Если пользователь должен иметь возможность сохранить текущий вид в закладку или поделиться им с коллегой — это в URL. Если потеря состояния не критична — держите его локально. Черновики формы — отдельный выбор. Многие команды хранят их только в состоянии формы, если нет явной потребности в восстановлении черновиков.

Перед тем как разбивать экран на компоненты, уберите дубли на бумаге. У одного значения должен быть один дом. Компоненты могут читать его, получать производные значения или отправлять обновления вверх, но не должны каждый хранить собственную версию.

Простая карта может быть достаточной: "products = server, filters = URL, edit draft = form, modal open = local." Этот маленький шаг формирует весь экран. Вы знаете, где фетчить, где сбрасывать и какое состояние никогда не копировать.

Пример: список продуктов с фильтрами и формой редактирования

Get CTO Input Early
Catch duplicate state and weak boundaries before they spread across the app.

Страница списка продуктов — хорошее место, чтобы увидеть владение состоянием в реальности. Один экран часто пытается выполнять пять задач одновременно: загрузить продукты, отфильтровать их, разбить по страницам, раскрыть строки и отредактировать один элемент. Если для каждого состояния не назначить владельца, страница быстро забрасывается мусором.

Используйте простую карту. Продукты приходят с сервера. Текст поиска и номер страницы живут в URL. Развёрнутые строки остаются локальными для страницы. Черновые правки — внутри формы.

Представьте команду, которая строит админку для маленького магазина. Страница открывается с ?q=chair\u0026page=2. Приложение читает URL, затем запрашивает продукты, соответствующие этому поиску и странице. Это значит, что сам список продуктов — серверное состояние, а не локальное состояние страницы. Если другой пользователь изменит продукт или бэкенд обновит остатки, сервер остаётся источником правды.

Поисковая строка и номер страницы должны жить в URL, потому что пользователи ожидают, что они переживут перезагрузку, навигацию назад/вперёд и копирование адреса. Если кто‑то отправит этот адрес коллеге, коллега должен увидеть тот же отфильтрованный список. Это сильный намёк, что это состояние не стоит прятать в сторе компонента.

Теперь посмотрите на разворачивание строк. Возможно, пользователь открыл две строки, чтобы увидеть детали. Этот выбор важен только на этой странице, в этом визите, для этого человека. Он не должен переживать перезагрузку, и серверу на него всё равно. Держите его локально в компоненте страницы.

Редактирование — совсем другое. Когда пользователь открывает форму для продукта, черновые название, цена и описание должны оставаться в состоянии формы до тех пор, пока он не нажмёт «Сохранить». Пока он печатает, не стоит мутировать строку списка, кеш‑объект продукта и превью в сайдбаре одновременно. Это создаёт дубли и мелкие баги, которые отнимают часы.

После сохранения отправьте обновление на сервер, затем получите свежие данные. В большинстве случаев это чище, чем патчить несколько копий вручную. Список обновится через проверенный путь ответа. Форма закроется, URL‑фильтры останутся на месте, а локальное состояние развёрнутых строк тоже можно оставить.

Это весь шаблон на одном экране: серверные данные идут по серверному пути, шарируемые настройки вида — в URL, временные UI‑детали — на странице, а несохранённые правки — в форме.

Ошибки, которые создают дубли состояния

Review Filters And Forms
Move shareable state to the URL and keep drafts where they belong.

Дублирование состояния обычно начинается с осторожного решения, которое кажется безобидным. Кто‑то копирует данные API в локальное состояние «на всякий случай», и приложение какое‑то время работает нормально. Потом серверные данные обновляются, копия не обновилась, и люди начинают гоняться за багами, которые проявляются только после перезагрузки, рефетча или в второй вкладке.

Страница списка продуктов — частый пример. Сервер уже владеет списком продуктов, ценами и количеством на складе. Если компонент копирует этот список в локальное состояние только чтобы отсортировать, отфильтровать или изменить одну строку, вы получаете две истины. Одна показывает, что товар в наличии, другая всё ещё показывает вчерашнее число.

Черновики форм часто портят всё в другую сторону. Команды кладут каждый черновик в глобальный стор, чтобы было удобно доступаться. Но в большинстве случаев черновик принадлежит одному экрану и одному действию пользователя. Если всё приложение может с ним работать, черновик может жить дольше, чем нужно, утечь на другую страницу или быть перезаписан чужим кодом.

Шарируемые фильтры создают ещё одну тихую проблему, когда их прячут не в URL. Если текущий поиск, сортировка, выбранная категория и номер страницы живут только в памяти, пользователи теряют их при перезагрузке. Они также не могут отправить точный вид коллеге. Фильтры, которые определяют, что показывает страница, обычно принадлежат URL, а не приватному стору.

Бардак усиливается, когда одно и то же значение живёт в двух сторaх. ID выбранного элемента может быть в URL и одновременно в Redux или Zustand. Теперь каждое изменение требует синхронизации, а синхронизация ломается чаще, чем все признают. Одного пропущенного обновления достаточно, чтобы сайдбар, список и панель деталей расходились.

Команды также поднимают состояние выше, чем нужно, только чтобы избежать передачи пропсов. Сначала это кажется аккуратно, но часто превращает родителя в кладовку для данных, которые ему не нужны. Позже ребёнку нужен тот же значение в немного иной форме, и кто‑то делает ещё одну копию. Так маленькое упрощение превращается в разрастание UI.

Хорошее владение состоянием — это в основном сдержанность. Выберите одного владельца для каждого фрагмента данных и сделайте так, чтобы остальные части приложения читали у него. Серверные данные остаются в серверном кеше. Черновые вводы — в форме. Шарируемые настройки вида — в URL. Маленькие переключатели UI — локально.

Если вас тянет зеркалить данные, остановитесь и задайте простой вопрос: что будет, если оставить только одну копию? В большинстве случаев ответ — «ничего», и это более безопасный дизайн.

Быстрые проверки перед добавлением ещё одного стора

Большинство багов со состоянием начинается с разумного упрощения. Команда добавляет ещё один стор для фильтров или модалки, а через месяц никто не знает, какое значение актуально. Прояснение наступает, когда перед сохранением новой вещи задают пару простых вопросов.

Если значение можно вычислить из уже имеющихся данных, не храните его. Количество после фильтра, состояние отключённой кнопки или отсортированный список часто относятся к рендер‑логике, а не в стор. Производное состояние быстро устаревает, потому что кто‑то обновляет базовые данные и забывает копию.

Проверьте и сохранность. Если при перезагрузке значение должно остаться — URL обычно лучше, чем состояние компонента. Флаг hover или открытое выпадающее меню обычно должно исчезать, значит локального состояния достаточно.

Перед добавлением стора прогоните короткий тест. Если другой экран нуждается в том же значении, назначьте один общий владелец вместо синхронизации копий. Если люди должны иметь возможность сохранить или поделиться видом — поместите его в URL. Если библиотека форм уже справляется с dirty‑полями, валидацией и статусом сабмита — используйте её. Если значение приходит от сервера и другие пользователи могут его изменить — считайте сервер владельцем. Если значение — вычисление на основе существующих данных — вычисляйте его при рендере.

Страница админки продуктов ясно это показывает. Список приходит с сервера. Текущий поисковый запрос и выбранная категория логично в URL, потому что коллега должен открыть тот же отфильтрованный вид. Форма редактирования владеет черновым названием и ценой до сохранения. Метку «12 совпадающих продуктов» не стоит никуда сохранять — это просто математика.

Дополнительные сторы кажутся полезными, потому что облегчают доступ к значениям. Они же упрощают создание багов. Когда два места могут обновлять один факт, одно из них обычно расходится.

Если не уверены, выбирайте самый узкий домен, который всё ещё соответствует ожиданиям пользователя. Большинству приложений нужно меньше общего состояния, чем команды думают.

Что делать дальше

Fix Ownership Before Refactors
Map one screen, choose clear owners, and cut avoidable rework.

Выберите один экран, который уже вызывает трения. Страница списка продуктов с фильтрами, страница настроек или форма редактирования подойдут. Соберите команду у доски и отметьте каждое значение, где оно должно жить.

Держите пометки простыми. Данные из бэкенда — это серверное состояние. Временные детали UI — локальное состояние. Несохранённые вводы — состояние формы. Всё, что пользователь может перезагрузить, сохранить в закладку или поделиться — в URL.

Это упражнение звучит базово, но многое проясняет. Команды перестают спорить о инструментах и начинают говорить об ответственности. Обычно именно тогда владение состоянием перестаёт быть абстрактным.

Напишите четыре коротких правила и держите их простыми. Серверное состояние приходит с бэкенда, значит ему нужны фетчинг, кеширование и рефетчинг. Локальное состояние остаётся рядом с компонентом, если только эта часть страницы о нём заботится. Состояние формы отслеживает правки пользователя до сохранения. Состояние URL хранит фильтры, порядок сортировки, пагинацию или активную вкладку, когда эти выборы должны пережить перезагрузку.

Затем переносите эти правила в код‑ревью. Прежде чем кто‑то добавит стор, context или эффект синхронизации, спросите: кто первым владеет этими данными? Уж не бэкенд ли? Должно ли состояние переживать перезагрузку или шаринг? Это черновик до сохранения? Нужны ли несколько частей страницы одно и то же живое значение?

Эти вопросы ловят удивительно много плохих решений на раннем этапе. Они также ускоряют ревью, потому что команда имеет общий тест вместо личных предпочтений.

Если приложение уже перепутано, внешний архитектурный аудит может помочь. Oleg Sotnikov at oleg.is работает со стартапами как Fractional CTO и советник, и такой аудит состояния часто обходится дешевле, чем исправления после очередной фичи.

Начните с одного экрана, одной диаграммы и четырёх правил. Этого часто достаточно, чтобы остановить разрастание, прежде чем оно станет постоянным налогом на каждую фичу.

Часто задаваемые вопросы

What does state ownership mean in frontend apps?

Владение состоянием означает, что за каждый фрагмент данных отвечает одно место. Все остальные части читают это место или отправляют туда обновления. Это правило предотвращает расхождение копий.

How do I tell if data belongs to server state or local state?

Спросите, откуда приходит значение и кто может его изменить. Если значение создаётся на бэкенде и другие пользователи могут его менять, держите его как серверное состояние. Если это короткое действие в рамках одного экрана — храните локально.

When should I store state in the URL?

Поместите параметры в URL, когда пользователи ожидают, что при перезагрузке, навигации назад/вперед, закладках или при шаринге вид останется тем же. Текст поиска, номер страницы, сортировка и активная вкладка часто подходят для URL.

Why should form state stay separate from server state?

Держите черновые данные в форме, пока пользователь явно не сохранит. Если смешать правки формы с полученными с сервера данными, рефетчи могут сбрасывать поля или показывать полуотредактированные значения в других местах.

Should I copy fetched API data into component state?

Обычно нет. Копирование полученных через API данных в локальное состояние создаёт две истины, и одна из них рано или поздно устареет. Читайте данные из слоя получения данных и храните локальные UI‑выборы рядом с компонентом.

When should I lift state up?

Поднимайте состояние наверх только тогда, когда две части интерфейса одновременно нуждаются в одном живом значении. Если компонент владеет состоянием и никто больше его не использует — оставьте его там.

Do I need a global store for filters and modals?

Чаще всего — нет. Фильтры обычно лучше в URL, а состояние открытия модалки — рядом с компонентом, который её рендерит. Глобальный стор нужен только когда несколько далеких частей действительно разделяют одно живое значение.

What counts as derived state?

Производное состояние — это значение, которое можно вычислить из имеющихся данных. Число результатов после фильтра, состояние отключённой кнопки или отсортированный список обычно стоит вычислять при рендере, а не хранить ещё одну копию.

How do I map state before I build a screen?

Запишите каждое видимое на экране значение, затем назначьте для него владельца. Отметьте, где оно начинается и кто может его изменить. Эта быстрая карта покажет дубликаты до того, как они попадут в код.

What should I fix first on a screen that already has messy state?

Начните со страницы, которая уже хрупкая. Сначала удалите копии серверных данных, затем перенесите шарируемые фильтры в URL и держите черновики в форме. Небольшая чистка на одной странице часто убирает много багов.