open atlas
↑ К треку
Архитектура фронтенда FE · 01 · 01

Форма состояния: решение, которое принимаешь до выбора библиотеки

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

FE Junior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Выкатили UI фильтров. Пользователи жалуются: бейдж со счётчиком врёт — «127 результатов», а в списке 9. Это не баг рендера: кто-то положил count в useState рядом с массивом results. Один setResults обновился, второй — нет. Два значения, которые должны были быть одним. Бейдж врал три недели, прежде чем кто-то завёл тикет.

Форма — это решение, а не дефолт

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

Прежде чем тянуться к Redux, Zustand, Jotai или React Query, ты принимаешь решение потише, но важнее: что вообще является состоянием, где оно живёт и оно ли источник истины или копия другого. Угадаешь форму — и большинство «проблем управления состоянием» не появятся. Ошибёшься — и никакая библиотека не спасёт: ты просто размажешь тот же drift по более красивому API.

Форму любого значения решают три вопроса:

  1. Это вообще состояние или производное? Если можно вычислить из существующего состояния прямо в рендере — это не состояние. Хранение создаёт второй источник истины, который рано или поздно разойдётся.
  2. Чьё это состояние — клиента или сервера? Список заказов, полученный из API, — это серверный кэш, а не клиентское состояние. Моделировать его как клиентское — самая частая архитектурная ошибка во фронтенде.
  3. Кто его читает? Этот ответ — один компонент, поддерево или всё приложение — задаёт, где значение живёт и насколько велик радиус перерендеров.

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

Ловушка производного состояния

Баг с бейджем выше — каноничный провал. 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. 1 Выводимо ли из существующего состояния? Если да — не храни, вычисляй в рендере
  2. 2 Если это состояние: оно серверное (полученное) или клиентское (UI/черновик/выбор)?
  3. 3 Серверное → кэш-библиотека (React Query/SWR), с ключом по запросу
  4. 4 Клиентское и шарящееся/в закладки → URL
  5. 5 Клиентское и приватное → наименьший общий предок его читателей (колокация)
Вспомните перед уходом
  1. 01
    Объясни коллеге, почему хранить count рядом с массивом results — это бомба замедленного действия, и что делать вместо этого.
  2. 02
    Почему «серверный кэш vs клиентское состояние» — самый важный вопрос формы, и как он меняет инструментарий?
Итог

Форма состояния решается до любой библиотеки, и она решает, какие баги возможны. Всё выводимое надо выводить в рендере, а не хранить, иначе оно становится вторым источником истины, который разойдётся. Полученные данные — это серверный кэш: отдай их кэш-библиотеке, которая держит свежесть, рефетч, дедуп и инвалидацию, вместо ручного моделирования как клиентского состояния. Шарящееся view-состояние живёт в URL; остальное колоцируется у наименьшего общего предка читателей, что держит радиус перерендеров маленьким. Когда ты по-настоящему держишь реляционные данные на клиенте, нормализуй их в мапу byId, чтобы обновления оставались O(1) и не расходились. Теперь, когда встретишь бейдж, врущий насчёт того же списка, — твой первый вопрос не «какой рендер пропустили?», а «какой второй источник истины разошёлся и как его удалить?»

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.