Где должна находиться граница фреймворка в многократно используемой стек-архитектуре интерфейса
Узнайте, как состоянийные машины, веб-компоненты, а также лейаут и анимации на основе атрибутов позволяют поведению интерфейса существовать независимо от фреймворка, и когда такая переносимость теряет смысл.
Большинство команд отлично решают проблемы интерфейса именно для одной фреймворковой платформы. Тщательно разработанный набор компонентов Angular, совокупность примитивов React, система дизайна, интегрированная в жизненный цикл конкретного рендерера — всё это работает безупречно до тех пор, пока проекту не понадобится другой инструмент, после чего почти вся сделанная инвестиция остаётся бесполезной. Разделение переиспользуемого интерфейса на слои (бездействия, рендеримые компоненты и небольшие возможности HTML, такие как макетирование и анимации) позволяет осознанно решить, какие слои следует связывать с фреймворком, а какие — нет.
Проблема решения задач интерфейса только для одного фреймворка
Представьте команду фронтенд-разработчиков, которая большую часть времени тратит на работу с Angular и довольна развитием этой фреймворковой платформы: сигналы, современные API и обширная структура для приложений, в которых это необходимо. Их внутренний набор инструментов следует модели shadcn, при которой исходный код компонентов копируется в каждый проект, после чего он становится его собственностью; при этом для работы на низком уровне используются специфичные для Angular безголовые примитивы. Внутри приложения на Angular это отличная конфигурация.
Проблемы начинаются тогда, когда следующий проект не является проектом на Angular. Крупное приложение с большим количеством состояний и интенсивным взаимодействием может идеально подойти для Angular. В то же время в основном статический маркетинговый сайт чаще всего лучше реализуется с использованием Astro. Иногда сама браузерная платформа уже обеспечивает почти всё необходимое. Если технология должна соответствовать требованиям каждого проекта, а не наоборот, то возникает неизбежный вопрос:
Какую часть вашей инфраструктуры интерфейса следует сохранить при смене фреймворка?
Попытка найти ответ на этот вопрос приводит к ряду идей: сначала веб-компоненты, затем безголовые интерфейсы, после этого машины состояний, и наконец менее традиционный эксперимент, при котором для форматирования макета и анимаций создаются собственные HTML-атрибуты. Сначала они кажутся не связанными между собой. Однако при рассмотрении вместе они оказываются разными способами решения одной дизайнерской проблемы: насколько можно делить интерфейс, не допуская проникновения элементов фреймворка в каждую абстракцию?
Безголовые интерфейсы — это не то же самое, что независимость от фреймворка
Безголовые интерфейсы уже решают значительную часть этой проблемы. Radix Primitives — яркий пример того, почему такая модель стала популярной. Вместо того чтобы поставлять диалоговое окно с цветом фона, отступами, тенью и радиусом границы, заданными кем-то другим, этот инструмент поставляет сложные компоненты, оставляя внешний вид на усмотрение пользователя.
Эти сложные аспекты представляют собой настоящую работу: управление вниманием, навигация с помощью клавиатуры, правильные атрибуты ARIA, поведение при закрытии и множество деталей, которые легко игнорировать, пока модальное окно кажется просто ещё одним элементом div, размещенным поверх другого. Radix намеренно поставляет свои примитивы без стилей и представляет их как доступные примитивы React. Вы контролируете внешний вид, но основная модель компонентов остается принадлежащей React.
Это может показаться очевидным, но у этого есть последствия. Устранение зависимости от стилей не устраняет зависимости от фреймворка. То же самое касается Angular: примитив может быть полностью нейтральным к цветам, отступам и типографике, при этом полностью завися от директив Angular, сигналов, внедрения зависимостей и хуков жизненного цикла.
Это не недостаток. Часто это именно правильный выбор. Примитив, разработанный для Angular, может тесно интегрироваться с ним, а примитив React может использовать модель композиции React. Глубокая интеграция обычно обеспечивает лучший опыт разработки, чем притворство, что фреймворк отсутствует. Суть в том, что две концепции часто рассматриваются как синонимы, хотя это не так:
headless
≠
framework-agnostic
Безвизуальные компоненты исключают визуальные аспекты. Абстракции, независимые от фреймворка, идут на еще один уровень глубже и пытаются избавиться от предположений относительно самого рендерера. Это два разных уровня повторного использования, и важно знать, какой из них вам действительно нужен.
Поведение может находиться ниже уровня компонента
Возьмём выпадающий список. Два таких списка могут совершенно не похожи визуально: один находится на странице маркетинга с крупным шрифтом, обширными пробелами и анимированными переходами; другой — в тесной панели инструментов IDE, где каждый пиксель на счёту. Один из них может быть рендерингован с помощью Angular, другой — React, а третий — веб-компонентом.
Под внешними различиями постоянно возникают одни и те же вопросы:
- Открыт ли выпадающий список в данный момент?
- Какой элемент является активным?
- Что делает клавиша Escape?
- Может ли пользователь перемещаться между элементами с помощью стрелочных клавиш?
- Как обрабатываются отключённые элементы?
- Куда переходит фокус после закрытия выпадающего списка?
Ни один из этих вопросов не зависит от того, Angular или React создавали маркировку. Это прежде всего проблемы взаимодействия, а уже затем — проблемы рендеринга.
Машины состояний как общий контракт
Именно здесь интерфейсы, управляемые состоянием машин, становятся особенно привлекательными. Zag.js — самый известный пример такого подхода. Вместо того чтобы рассматривать компонент React как источник истины, Zag моделирует взаимодействие как машины, независимые от конкретной фреймворковой среды. Отдельные адаптеры фреймворков соединяют эти машины с механизмами реактивности, жизненного цикла и работы с DOM в React, Vue, Solid, Svelte и других фреймворках, причем в документации объясняется, как написать адаптер для фреймворка, который пока не поддерживается.
В традиционном компоненте все аспекты реализации объединены в одну единицу, специфичную для конкретного фреймворка:
React Dropdown
├─ state
├─ interactions
├─ accessibility
└─ rendering
Благодаря наличию машины посередине поведение определяется один раз, а каждый рендерер использует его:
Dropdown behavior
│
state machine
│
┌────────────┼────────────┐
↓ ↓ ↓
React Vue Svelte
Финальные компоненты по-прежнему остаются отдельными компонентами, и каждая фреймворк-система продолжает вести себя как фреймворк. То, что было перемещено, — это поведенческий контракт, который представляет собой более полезное определение понятия фреймворка, независимое от конкретных решений, чем идея о каком-то волшебном компоненте, который будет работать везде без необходимости интеграции. Лучшее описание звучит так: написать поведение один раз, а затем адаптировать его под тот рендерер, который наиболее подходит для приложения.
Полезной моделью для понимания является представление о том, что машина представляет собой чистое описание состояний, событий и переходов между ними. Поскольку в ней отсутствуют ссылки на DOM и состояния фреймворка, её легко тестировать изолированно: достаточно отправить на неё события и проверить полученное состояние, без необходимости подключения каких-либо компонентов.
Компонент может существовать в виде поведения ещё до того, как станет частью интерфейса
Та же идея применима в библиотеках веб-компонентов. Представьте библиотеку, созданную с использованием Stencil, которая содержит обычные кнопки, диалоговые окна, выпадающие списки и карточки. Эти компоненты не требуют замены на машины состояний. Вместо этого под ними может находиться отдельный слой.
Фабрика кнопок знает о состояниях «отключено» и «загрузка», обработке нажатий и том, какие свойства должны применяться к интерактивному элементу. Фабрика выпадающих списков знает о процессах открытия, закрытия, выбора и навигации с помощью клавиатуры. Фабрика диалоговых окон знает о их жизненном цикле и правилах взаимодействия. Ни одна из этих логик не должна решать, как будет выглядеть компонент.
Концептуально что-то подобное может существовать ещё до принятия решения о отрисовке:
const button = createButton({
disabled: false,
loading: false,
onClick(event) {
// application behavior
},
});
Обратите внимание на отсутствующее: нет Stencil, нет Angular, нет компонента React, даже CSS отсутствует. Фабрика представляет собой лишь описание поведения, к которому позже можно привязать рендерер. Например, кнопка Stencil из библиотеки использует это поведение и предоставляет стилизованный пользовательский элемент:
<and-button variant="destructive">
Delete
</and-button>
Другое приложение может обернуть совершенно другую кнопку вокруг того же ядра поведения. Возьмем IDE в стиле настольной программы, интерфейс которого намеренно гораздо более насыщенный, чем у обычного веб-сайта. Его кнопки используют другие размеры, другие дизайнерские элементы и отличный визуальный стиль, поэтому импортирование универсальной кнопки Web Component был бы неверным решением. У IDE просто есть собственная кнопка Angular, и эта кнопка всё равно может вызывать ту же фабрику createButton().
Вот практическая выгода. Цель не в этом:
ONE BUTTON
↓
use everywhere
Цель заключается в следующем:
shared behavior
│
┌──────────┴──────────┐
↓ ↓
Web Component Angular component
↓ ↓
Web UI IDE UI
Отображение остается локальным для каждого продукта, в то время как сложная логика взаимодействия больше не требует переписывания для каждого из них.
Web Components делают отрисованный компонент переносимым
Машины состояний делают поведение переносимым. Они не влияют на отрисованное пользовательское интерфейсное оформление, и именно здесь Web Components сохраняют свою ценность. Такой пользовательский элемент, как показано ниже, принадлежит к веб-платформе, а не к Angular, React или Vue:
<and-button>
Save
</and-button>
Angular может его отрисовать, Astro может его выдавать, React может им пользоваться, а обычная HTML-страница может включить его с помощью тега script. Фреймворк, используемый для его работы, может меняться, в то время как сам элемент остается абсолютно неизменным.
На практике поддержка пользовательских элементов в фреймворках отличается по деталям. Angular требует наличия CUSTOM_ELEMENTS_SCHEMA (или эквивалента) для обработки неизвестных тегов, а React ранее передавал значения в виде атрибутов, а не свойств, что затрудняло работу с сложными данными и пользовательскими событиями, хотя в новых версиях ситуация улучшилась. Прежде чем приступать к использованию этого подхода, стоит проверить текущую поддержку пользовательских элементов в вашем фреймворке.
В итоге существуют два разных вида повторного использования. Один из них — это переносимое поведение:
State machine
↓
portable behavior
Другой — это переносимый отрисовываемый компонент:
Web Component
↓
portable rendered component
Иногда вам нужен целый веб-компонент. Если элемент кнопки в системе дизайна должен выглядеть и вести себя одинаково в нескольких приложениях, его упаковка в виде пользовательского элемента является разумным решением. В других случаях вам совсем не нужен полноценный компонент. Сценарий среды разработки — именно такой случай: сохраняется логика взаимодействия, но рендеринг и дизайн полностью остаются в ведении самого приложения.
Это не конкурирующие архитектуры; они просто размещают границы абстракции в разных местах. Рассмотрение границ вместо библиотек также проясняет ещё кое-что: не каждый повторно используемый элемент интерфейса обязательно должен быть компонентом.
Для макетирования не нужен компонент
Макетирование — самый яркий пример. Вот обычный элемент Tailwind:
<div
class="
flex
flex-col
items-start
gap-6
p-6
rounded-xl
border
bg-card
shadow-sm
"
>
...
</div>
В этом нет ничего плохого. Одним из настоящих преимуществ Tailwind является возможность понять большую часть свойств элемента без постоянного перехода между шаблоном и таблицей стилей. Но посмотрите, сколько функций выполняет один только атрибут class. Некоторые из классов определяют визуальный облик:
rounded-xl
border
bg-card
shadow-sm
Другие классы описывают пространственные отношения между элементом и его дочерними элементами:
flex
flex-col
items-start
gap-6
p-6
Браузер не делает между ними различий. class — это просто универсальный механизм для добавления идентификаторов, которые могут быть выбраны с помощью CSS и JavaScript; в HTML нет понятия «класса макета» и «класса дизайна». Однако с точки зрения проектирования API их разделение оказывается удобным. Экспериментальный атрибут and-layout отдельно выражает пространственную часть:
<div
class="card"
and-layout="vertical align:start gap:lg p:lg"
>
...
</div>
Преимущество заключается не в краткости; иногда эта версия бывает такой же длинной. Преимущество в том, что ответственность становится очевидной: class описывает, как выглядит элемент, а and-layout — как он распределяет пространство.
Как реализуется атрибут
Для его реализации не требуется ни фреймворк, ни JavaScript. Это чистый CSS: каждый элемент атрибута сопоставляется со спецификаторами атрибутов (спецификатор ~= для разделенных пробелами слов идеально подходит) и связывается с правилами Flexbox, Grid, формирования отступов и адаптивности. Такая декларация в конечном итоге представляет собой просто CSS Grid с точками разрыва:
<div
and-layout="grid cols:1 cols@md:2 cols@lg:3 gap:lg"
>
За этим не скрывается никакой движок макетирования, нет директив Angular, компонентов React и подобных оберток, если только вы действительно не хотите использовать Stack:
<Stack direction="vertical" gap="lg">
Это важно. Компоненты макета — не плохая идея. Абстракция <Stack> может оказаться очень удобной, особенно в системе проектирования фреймворков. Подход с атрибутами — это просто ещё один вариант: если нужный элемент уже существует, возможно, не обязательно создавать дополнительный компонент только для описания того, как располагаются его дочерние элементы.
Следует учитывать компромисс: согласно спецификации, пользовательские атрибуты без префикса data- не являются корректным HTML, хотя все браузеры с радостью их стилизуют. Валидаторы и некоторые инструменты проверки кода будут выдавать ошибки, а использование написания data-and-layout позволяет избежать этого, но при этом увеличивается объём текста.
class может делать всё, но не обязан этого делать
За этим стоит более общая закономерность. Современные маркапы позволяют возложить огромную часть ответственности на атрибут class. Если добавить утилитные CSS-правила и плагин для анимаций, обычный элемент может превратиться во что-то подобное:
<div
class="
card
flex
flex-col
items-center
gap-6
p-8
rounded-xl
border
bg-card
animate-in
fade-in
slide-in-from-bottom-4
duration-500
"
>
Этот подход работает, и читаемые утилитные классы гораздо предпочтительнее, чем попытки сделать так, чтобы каждый интерфейс требовал идеально семантической иерархии BEM. Настоящий вопрос не в том, может ли class нести всю эту нагрузку; очевидно, что может. Вопрос в том, должен ли один атрибут отражать все возможности элемента. Разделение функций позволяет получить:
<div
class="card"
and-layout="vertical align:center gap:lg p:xl"
and-motion="slide-in-up"
and-motion-duration="500ms"
>
Теперь элемент с первого взгляда демонстрирует три отдельные функции:
class → visual identity
and-layout → spatial organization
and-motion → animation
Представьте это как небольшое применение принципа единственной ответственности к маркапу. HTML этого не требует. Мотивация заключается в том, что такой результат легче читать, проверять и изменять.
Для анимаций создан отдельный декларативный канал
Система анимаций основана на подходе, который эффективно работает уже много лет. Animate.css, размещенный по адресу animate.style, управляет анимациями исключительно с помощью классов: достаточно добавить базовый класс вместе с названием анимации, и элемент начнет анимироваться.
<h1 class="animate__animated animate__bounce">
Hello
</h1>
Этот подход прост, знаком и практически не требует дополнительных процедур. Плагины анимаций, ориентированные на Tailwind, следуют той же философии, превращая анимации в еще одну группу комбинируемых утилит в формате class.
Интересный вопрос здесь заключается не в том, как создать более мощную систему анимаций. Это связано с вопросом о макетировании: если анимации — это отдельная задача, у них может быть свое отдельное декларативное место в разметке. Вместо того чтобы располагать утилиты анимаций рядом со всем остальным:
<div
class="
card
animate-in
fade-in
slide-in-from-bottom-4
duration-500
"
>
анимацию описывают отдельно:
<div
class="card"
and-motion="slide-in-up"
and-motion-duration="500ms"
>
Когда важен триггер, его необходимо явно указать вместе с задержкой и продолжительностью:
<div
class="card"
and-motion="slide-in-up"
and-motion-trigger="enter"
and-motion-duration="800ms"
and-motion-delay="200ms"
>
Маркировка определяет, какая анимация будет выполняться, когда и как она будет синхронизирована по времени; реализация занимается всем остальным. Для триггера enter это, скорее всего, означает использование IntersectionObserver для отслеживания элемента. Триггеры наведения курсора и нажатия используют собственные обработчики. Важно также то, что медиа-запрос prefers-reduced-motion можно учитывать в одном центральном месте, вместо того чтобы полагаться на то, что каждый автор компонента об этом вспомнит.
Атрибуты — это не магия. Они предоставляют способ для элемента объявить о своих возможностях, не вставляя его в дерево компонентов фреймворка.
Декларативный подход до тех пор, пока он помогает
У этого подхода есть пределы. Простая анимация появления элемента идеально вписывается в один атрибут:
and-motion="fade-in"
Переход при закрытии модального окна представляет собой другую категорию проблем. Предположим, что модальное окно должно анимированно исчезать и удаляться из DOM только после завершения анимации. В таком случае важны этапы жизненного цикла и порядок действий, и простой императивный код позволяет более четко выразить замысел:
await player.play('fade-zoom-out');
closeModal();
Попытка закодировать каждую взаимосвязь жизненного цикла в постоянно растущем атрибуте HTML только ухудшит декларативный API, а не улучшит его. Основное правило заключается в выборе самого простого уровня, который точно отражает проблему — будь то обычный CSS, атрибут, машина состояний, обычный сервис фреймворка или веб-компонент. Декларативный стиль не должен превращаться в догму. Никто не пытается избавиться от JavaScript или фреймворков. Цель — избегать перехода к самой сложной доступной абстракции только потому, что она существует.
Атрибуты как небольшие API функциональности
Как только как макет, так и анимация следуют этой схеме, атрибуты начинают выглядеть не столько как параметры настройки, сколько как небольшие API для реализации функций. Рассмотрим этот фрагмент:
<section
class="feature-card"
and-layout="vertical gap:lg p:xl"
and-motion="fade-in"
and-motion-trigger="enter"
>
<h2>Framework-agnostic UI</h2>
<p>At least as much as possible.</p>
</section>
Это по-прежнему <section>, и его семантическое значение остаётся прежним. Чтобы получить отступы, форматирование и анимацию появления, не требовалась такая стопка обёрток:
<and-stack>
<and-motion-container>
<and-card>
...
</and-card>
</and-motion-container>
</and-stack>
Такая вложенная версия не обязательно является неверной. Если эти элементы обеспечивают значимое поведение и предоставляют полезный API, они вполне могут считаться компонентами. Однако различие более узкое: переиспользуемая функциональность не обязательно должна превращаться в ещё один компонент. Часто узел DOM уже существует, и ему требуется лишь форматирование, анимация или поведение подсказок. Фреймворку не всегда нужно об этом знать, и когда абстракция находится непосредственно в HTML, она может быть использована без каких-либо дополнительных усилий в Angular, Astro, React, Vue или на странице без фреймворка.
Отсутствие зависимости от фреймворка не означает отсутствие самого фреймворка
Здесь существует очевидный риск: понятие «независимый от фреймворка» может легко превратиться в ещё один конкурс «чистоты», что полностью сделает бессмысленной цель. Фреймворки заслуживают своего места благодаря возможности интегрировать различные компоненты. Angular предоставляет сигналы, шаблоны, механизм внедрения зависимостей, формы, систему маршрутизации и чёткую модель приложения. React имеет очень развитую экосистему композиции. Vue и Svelte предлагают совершенно другие компромиссы.
Поведение, независимое от фреймворка, всё равно должно быть связано с реактивной системой каждого приложения. Именно поэтому Zag предоставляет адаптеры фреймворков: адаптер связывает приложение с правилами реактивности, жизненного цикла и работы с DOM конкретного фреймворка, и приложение может оставаться независимым только потому, что другой слой понимает этот фреймворк.
Описанный здесь многоуровневый подход также сопряжён с теми же проблемами:
- Компонент Angular, построенный на универсальном фабрике состояния, может требовать использования метода
effect()для синхронизации входных сигналов с внешним состоянием. - Веб-компонент должен связать слой состояния со своими собственными обработчиками жизненного цикла.
- Метод расположения, основанный на атрибутах, предусматривает простой DSL, который необходимо изучить каждому члену команды.
- Атрибуты движения добавляют ещё один интерфейс API.
- Разделение всего на отдельные пакеты приводит к созданию контрактов, которые должны оставаться совместимыми со временем.
Иногда простой компонент, специфичный для определенной платформы, действительно является лучшим решением с точки зрения дизайна. Именно поэтому набор компонентов, предназначенный исключительно для Angular и готовый к копированию, по-прежнему имеет смысл использовать наряду со всем остальным. Никто не должен заставлять каждую кнопку в Angular проходить через цепочку из четырех адаптеров, пару машин состояний и веб-компонент только потому, что термин «агностичный» звучит более сложным. Это очень дорогостоящий способ избежать написания:
<volt-button>
Независимость от фреймворка оправдывает себя тогда, когда портабельность является реальной необходимостью. Когда же это не так, тесная интеграция с фреймворком часто оказывается лучшим решением.
Интерфейс, не зависящий от фреймворка, как стек, а не библиотека
Фраза «библиотека интерфейса, не зависящая от фреймворка» обычно ассоциируется с набором компонентов, которые каким-то образом работают везде. Web Components довольно близки к этому для отрисовываемых компонентов. Однако более полезной моделью является не единая универсальная библиотека, а набор функциональных слоев, каждый из которых становится специфичным для определенного фреймворка в своем месте:
Application
│
Angular / React / Vue / Astro
│
framework adapters
│
─────────────────────────────────────────
│
headless behavior / state machines
│
Web Components HTML capabilities
│ │
│ layout / motion attributes
│ │
─────────────────────────────────────────
│
Web Platform
Ни одно приложение не обязано использовать все слои:
- В одном проекте напрямую используется Web Component, и он никогда не взаимодействует с подлежащим ему безголовым состоянием.
- В другом проекте используется только машина состояний, а свой интерфейс Angular создается поверх нее.
- Статическая страница Astro может не нуждаться ни в чем, кроме атрибутов макета и анимации.
- Приложение на Angular может игнорировать весь этот набор слоев и использовать нативный набор инструментов Angular с его примитивами, поскольку это обеспечивает наилучший опыт разработки.
Вся суть заключается в возможности комбинировать разные элементы. Термин «независимый от фреймворка» не означает, что использование фреймворка запрещено. Он означает, что вы сами определяете границы применения фреймворка, вместо того чтобы позволять ему автоматически распространяться на каждый слой, который можно повторно использовать.
Размещение фреймворка на уровне приложения
Все это не является аргументом против Angular или любого другого фреймворка. Это аргумент о том, какую ответственность фреймворк берет на себя по умолчанию. Рассмотрите примеры снова с этой точки зрения:
- Визуальный дизайн выпадающего списка может принадлежать продукту, а его отрисовка — Angular, но модель взаимодействия с ним не обязательно должна быть связана с Angular.
- Общий элемент Button может быть веб-компонентом, если нужен одинаковый элемент Button в нескольких проектах, или же кастомным компонентом Angular, который имеет лишь небольшой поведенческий ядро, если продукту требуется собственный визуальный стиль.
and-layout.Если рассматривать все это вместе, элементы составляют логичную картину:
- Headless UI устраняет визуальные элементы интерфейса.
- Машины состояний освобождают поведение от зависимости от конкретного рендерера.
- Web Components позволяют готовым, отрендеренным компонентам перемещаться между различными фреймворками.
- Специализированные атрибуты привязывают такие легковесные функции, как макетирование и анимации, непосредственно к HTML, а не к фреймворку.
Ни одна из этих идей не является новой, и не каждая система интерфейса должна строиться таким образом. Однако в совокупности они ставят под сомнение давно устоявшийся стандарт: то, что фреймворк обязательно должен находиться под каждой повторно используемой абстракцией интерфейса в приложении.
Основные выводы
- Различайте понятия «без стилей» и «независимый от рендерера»; большинство библиотек без графического интерфейса относятся только к первому типу.
- Логику взаимодействия, общую для нескольких продуктов, размещайте в машинных состояниях или фабриках без использования фреймворка, осознанно принимая связанные с этим затраты.
- Используйте Web Components тогда, когда сам компонент после рендеринга, а не только его поведение, должен быть идентичным в разных средах.
- Для формирования макета и простых анимаций используйте возможности атрибутов, основанные исключительно на CSS, а переходите к императивному коду, как только важна последовательность этапов жизненного цикла.
Связанные статьи
- Когда повторное использование React-компонентов приводит к проблемам: взрыв prop-ов и способы решения — Узнайте, как преждевременное повторное использование превращает простой React-компонент в источник проблем из-за большого количества prop-ов, и как дублирование, составные компоненты и правило трех помогают этого избежать.
- Переосмысление состояния в React: где на самом деле должны храниться данные — В этой статье объясняется, как сократить количество ошибок в React путем размещения состояния в URL, DOM или вычисляемых значениях вместо чрезмерного использования useState.