Архитектура фронтенда
Форма состояния: решение, которое принимаешь до выбора библиотеки
Выкатили UI фильтров. Пользователи жалуются: бейдж со счётчиком врёт — «127 результатов», а в списке 9. Это не баг рендера: кто-то положил count в useState рядом с массивом results. Один setResults обновился, второй — нет. Два значения, которые должны были быть одним. Бейдж врал три недели, прежде чем кто-то завёл тикет.
Форма — это решение, а не дефолт
Прежде чем тянуться к Redux, Zustand, Jotai или React Query, ты принимаешь решение потише, но важнее: что вообще является состоянием, где оно живёт и оно ли источник истины или копия другого. Угадаешь форму — и большинство «проблем управления состоянием» не появятся. Ошибёшься — и никакая библиотека не спасёт: ты просто размажешь тот же drift по более красивому API.
Форму любого значения решают три вопроса:
- Это вообще состояние или производное? Если можно вычислить из существующего состояния прямо в рендере — это не состояние. Хранение создаёт второй источник истины, который рано или поздно разойдётся.
- Чьё это состояние — клиента или сервера? Список заказов, полученный из API, — это серверный кэш, а не клиентское состояние. Моделировать его как клиентское — самая частая архитектурная ошибка во фронтенде.
- Кто его читает? Этот ответ — один компонент, поддерево или всё приложение — задаёт, где значение живёт и насколько велик радиус перерендеров.
Ловушка производного состояния
Баг с бейджем выше — каноничный провал. count — это results.length. В тот момент, когда ты хранишь count отдельно, ты берёшь на себя инвариант, который фреймворк с радостью держал бесплатно: каждый путь кода, меняющий results, обязан менять и count. Пропусти один путь — обработчик ошибки, оптимистичное обновление, патч по websocket — и значения разойдутся. UI показывает то, что было верным в каком-то прошлом рендере и так и не исправилось.
Рефлекс сеньора: если можно вывести — выводи. Вычисляй в рендере; тянись к useMemo только когда вычисление измеримо дорогое, а не по умолчанию. Мемоизация — инструмент производительности, а не корректности: добавление useMemo не уравнивает «вывести vs хранить», потому что хранимая копия остаётся вторым источником истины даже мемоизированной.
| Значение | Хранить как состояние? | Почему |
|---|---|---|
results из API | Нет — серверный кэш | Принадлежит серверу; нужна свежесть + рефетч, а не useState |
count = results.length | Нет — производное | Вычислимо в рендере; хранение приглашает drift |
isModalOpen | Да — локальное UI-состояние | Чисто клиентская забота, читает одно поддерево → колоцируй |
selectedId | Да — но храни id, а не объект | Объект выводится из id + кэша; настоящее состояние — это id |
Серверный кэш — не клиентское состояние
Самая крупная ошибка формы — загрузить серверные данные в useState/Redux и обращаться с ними так, будто они твои. Они не твои. У серверных данных есть свойства, которых у клиентского состояния не бывает: они устаревают, их можно рефетчить, их нужно дедуплицировать между компонентами, у них есть состояния загрузки и ошибки, их может инвалидировать мутация в другом месте. Когда ты моделируешь их как обычное клиентское состояние, ты вручную пишешь всё это — обычно плохо. Именно поэтому существуют React Query / TanStack Query, SWR и RTK Query: это библиотеки серверного кэша, а не состояния, и в этом различии вся суть.
Практический раздел, который проводит сеньор:
- Серверный кэш → React Query / SWR / RTK Query. С ключом по запросу, со свежестью, рефетчем, дедупом.
- Клиентское состояние →
useState(локальное), Context или Zustand/Jotai (общее). То, чего сервер никогда не знал: какая вкладка открыта, черновик формы, выбор. - URL → источник истины для всего, что шарится или кладётся в закладки: фильтры, пагинация, id открытой детали. Хранить это только в
useStateозначает: рефреш теряет состояние, а скопированная ссылка мертва.
Колокация задаёт радиус взрыва
Состояние живёт в наименьшем общем предке всех, кто его читает, — и не выше. Поднять значение в глобальный стор «чтобы было доступно» — самая частая причина перерендер-штормов на всё приложение: каждый компонент, подписанный на стор, перерендеривается при изменении, даже те, что не читали изменившийся срез. Мысль Кента Доддса прямая: переместить состояние вниз, туда, где его используют, делает приложение быстрее, потому что React перерендеривает только владеющее поддерево.
Цена реальна на масштабе. Глобальный стор с высокочастотным состоянием (позиция мыши, живой счётчик, нажатия в форме) перерендеривает каждого подписчика на каждое изменение, если не отбирать срезы аккуратно. Atom-библиотеки (Jotai, Recoil) существуют именно чтобы сжать радиус взрыва до компонентов, читающих конкретный атом. Но самый дешёвый фикс обычно структурный: не поднимай то, чем владеет одно поддерево.
Почему это работает
«Поднимай состояние наверх» (фраза из доков React) верна, но её рутинно перегибают. Поднимай к наименьшему общему предку, а не на самый верх. Подъём к корню — это как открыто/закрыто у тултипа начинает перерендеривать дашборд. Зеркальное правило — «опускай состояние вниз» — это и есть колокация на практике.
Нормализация: трейдофф для реляционного клиентского состояния
Когда ты действительно держишь реляционные данные в клиентском сторе (offline-first приложение, редактор с локальными сущностями), формируй их так, как сделала бы база: плоская мапа по id, а не вложенные массивы. { users: { byId: {...}, allIds: [...] } } вместо users, вложенных в posts, вложенных в threads.
Трейдофф конкретен. Вложенная форма делает чтение удобным (post.author.name), но обновление — кошмаром: поменяй имя одного пользователя, и придётся найти и переписать каждую копию, встроенную по всему дереву, — а пропустишь одну, снова drift, теперь на масштабе. Нормализованная форма делает обновление O(1) (запись одной записи в byId) ценой джойна при чтении (byId[post.authorId].name). Для всего, что часто мутируешь, — нормализуй. createEntityAdapter из Redux Toolkit существует, чтобы сделать это дефолтом. Для read-mostly серверных данных не парься — пусть кэш-библиотека держит сырой ответ.
Редактор держит на клиенте threads → posts → users, и пользователей постоянно переименовывают и редактируют. Выбери клиентскую форму.
Список товаров получают из API и показывают в трёх компонентах. Где он должен жить?
У тебя results: Item[] в состоянии, и нужно показать, сколько их. Какой ход сеньора?
Расставь вопросы, которые сеньор задаёт, чтобы решить, где живёт значение:
- 1 Выводимо ли из существующего состояния? Если да — не храни, вычисляй в рендере
- 2 Если это состояние: оно серверное (полученное) или клиентское (UI/черновик/выбор)?
- 3 Серверное → кэш-библиотека (React Query/SWR), с ключом по запросу
- 4 Клиентское и шарящееся/в закладки → URL
- 5 Клиентское и приватное → наименьший общий предок его читателей (колокация)
- 01Объясни коллеге, почему хранить count рядом с массивом results — это бомба замедленного действия, и что делать вместо этого.
- 02Почему «серверный кэш vs клиентское состояние» — самый важный вопрос формы, и как он меняет инструментарий?
Форма состояния решается до любой библиотеки, и она решает, какие баги возможны. Всё выводимое надо выводить в рендере, а не хранить, иначе оно становится вторым источником истины, который разойдётся. Полученные данные — это серверный кэш: отдай их кэш-библиотеке, которая держит свежесть, рефетч, дедуп и инвалидацию, вместо ручного моделирования как клиентского состояния. Шарящееся view-состояние живёт в URL; остальное колоцируется у наименьшего общего предка читателей, что держит радиус перерендеров маленьким. Когда ты по-настоящему держишь реляционные данные на клиенте, нормализуй их в мапу byId, чтобы обновления оставались O(1) и не расходились. Угадай форму — и большинство «проблем управления состоянием» просто не появятся.