Создание пуленепробиваемых React-компонентов¶
Большинство компонентов пишутся под «счастливый путь». В таком режиме они работают, но в продакшене быстро проявляются слабые места: серверный рендеринг, гидратация, несколько экземпляров, конкурентный рендеринг, асинхронные children, порталы и т.д. Компонент должен корректно работать во всех этих сценариях.
Критерий качества не в том, что компонент работает на вашей странице сейчас. Критерий — предсказуемое поведение в чужом окружении и при конфигурациях, которые вы заранее не планировали. Именно там обычно и возникают регрессии.
Ниже — практические приёмы, которые повышают устойчивость компонента.
Сделайте его устойчивым к серверу¶
Простой провайдер темы, который читает предпочтения пользователя из localStorage:
| Падает в SSR — читает тему из localStorage | |
|---|---|
Но на сервере localStorage не существует. В Next.js, Remix или любом другом SSR-фреймворке это ломает сборку. Перенесите обращения к браузерным API в useEffect:
| useEffect откладывает работу с localStorage только до клиентской стороны | |
|---|---|
Теперь компонент рендерится на сервере без падения.
Сделайте его устойчивым к гидратации¶
Серверобезопасная версия уже работает, но остаётся UX-дефект: видна вспышка неверной темы. Сервер рендерит светлую тему, клиент гидратируется, затем срабатывает эффект и переключает на тёмную:
| Вспышка неверной темы — useEffect запускается после гидратации | |
|---|---|
Вставьте синхронный скрипт, который задаёт корректное значение до того, как браузер отрисует страницу и React начнёт гидратацию. Тогда в DOM уже будет правильный класс в момент, когда React «перехватит управление»:
| Inline-скрипт задаёт тему до отрисовки браузером | |
|---|---|
Итог: нет ни рассинхронизации, ни визуальной вспышки.
Сделайте его устойчивым к нескольким экземплярам¶
Версия, устойчивая к гидратации, использует захардкоженный id="theme". Но что, если кто-то использует два ThemeProvider?
| Несколько экземпляров — оба скрипта нацелены на один и тот же ID | |
|---|---|
Оба скрипта будут работать с одним и тем же элементом, что приводит к гонке. Используйте useId для стабильного уникального ID на каждый экземпляр:
| useId генерирует уникальные ID для каждого экземпляра | |
|---|---|
Итог: несколько экземпляров работают независимо и без конфликтов.
Сделайте его устойчивым к конкурентному рендерингу¶
Теперь сделаем тему управляемой сервером. Серверный компонент, который получает пользовательские настройки:
| Серверный компонент получает настройки из базы данных | |
|---|---|
Если отрендерить компонент в двух местах, вы можете получить два одинаковых запроса в БД. Оберните запрос в React.cache, чтобы дедуплицировать вызовы в рамках одного серверного запроса:
| React cache() дедуплицирует конкурентные вызовы | |
|---|---|
Итог: одинаковый запрос, откуда бы он ни вызывался, обращается к базе один раз.
Сделайте его устойчивым к композиции¶
Иногда нужно передавать данные детям через пропсы, и традиционно для этого использовали React.cloneElement:
| Передаёт тему в children через cloneElement | |
|---|---|
Но в React Server Components, при React.lazy или "use cache", children могут быть Promise или непрозрачной ссылкой — cloneElement не сработает. Вместо этого используйте контекст:
| Контекст работает везде — сервер, клиент, async | |
|---|---|
Children получают тему через useContext — без проброса пропсов и без cloneElement.
Сделайте его устойчивым к порталам¶
Провайдер темы с клавиатурным шорткатом — Cmd+D для переключения тёмной темы:
Но если кто-то рендерит приложение во всплывающем окне, iframe или через createPortal, шорткат перестанет работать. Слушатель привязан к родительскому window, а не к тому, где живёт компонент. Используйте ownerDocument.defaultView:
Итог: шорткат работает в любом оконном контексте.
Сделайте его устойчивым к переходам¶
Панель настроек, которая переключается между простым и расширенным режимами:
| Простое переключение состояния между двумя панелями | |
|---|---|
Если обернуть это в <ViewTransition> из React 19, анимация не запустится — панели просто мгновенно сменятся. Обновление состояния должно выполняться через startTransition:
| startTransition включает анимацию перехода представления | |
|---|---|
Итог: переход анимируется плавно.
Сделайте его устойчивым к Activity¶
Тематический компонент, который внедряет CSS-переменные через тег <style>:
| Внедряет глобальные CSS-переменные через тег style | |
|---|---|
Если обернуть компонент в <Activity>, тёмная тема будет сохраняться даже в скрытом состоянии. Причина: <Activity> сохраняет DOM, а <style> создаёт глобальный побочный эффект (меняет переменные :root). React не очищает такие эффекты автоматически. Установите media="not all", чтобы отключать эти стили в скрытом состоянии:
| useLayoutEffect устанавливает media='not all' при скрытии и возвращает обратно при показе | |
|---|---|
Итог: к скрытым компонентам тёмная тема больше не применяется.
Сделайте его устойчивым к утечкам¶
Серверный компонент передаёт объект user (включая токен сессии) в другой компонент темы. Это валидный кейс — данные нужны на сервере. Вы можете знать, что UserThemeConfig — серверный компонент, и что передавать туда эти данные безопасно.
| Dashboard передаёт user (с токеном) в другой компонент | |
|---|---|
Однако вы не знаете точного поведения UserThemeConfig: что он рендерит сейчас и как может измениться в будущем. Этот компонент не находится под вашим контролем.
Кроме того, поскольку UserThemeConfig не создаёт user, компонент может не знать, что у user есть чувствительное поле token. Вы не контролируете этот компонент, поэтому нельзя предполагать, что он нигде в своём дереве не передаст токен в клиентский компонент. Тогда токен будет сериализован и отправлен на клиент. Используйте экспериментальный API React taintUniqueValue, чтобы пометить токен как доступный только на сервере. Если это значение попадёт в клиентский компонент, React выбросит ошибку. Чтобы заблокировать целый объект, а не отдельное значение, используйте taintObjectReference.
| taintUniqueValue блокирует отправку user.token на клиент | |
|---|---|
Если код этого компонента (или будущий рефакторинг в команде) попытается передать user.token в клиентский компонент, React выбросит ошибку с вашим сообщением. Рабочий сценарий сохраняется, а токен не утекает.
Сделайте его устойчивым к будущим изменениям*¶
Это принцип проектирования: пишите защитный код там, где это оправдано. Не применяйте его механически везде.
Тема, которая генерирует случайные акцентные цвета при монтировании:
| useMemo кеширует сгенерированные цвета | |
|---|---|
Но useMemo — это подсказка для производительности, а не семантическая гарантия. React сбрасывает кешированные значения во время HMR и оставляет за собой право делать это для offscreen-компонентов или возможностей, которых ещё не существует. Если React сбросит кеш, тема начнёт мигать разными цветами. Используйте состояние, когда корректность зависит от стабильного хранения значения:
Теперь цвета остаются стабильными независимо от внутренних оптимизаций React.
Это уже не «редкие кейсы», а типовые условия современной React-разработки. Если компонент ломается в них, проблема обычно не в случайности, а в неверных инженерных допущениях. Цель — проектировать компоненты под текущие и будущие сценарии выполнения.
Источник: https://shud.in/thoughts/build-bulletproof-react-components