Сама идея (держать рендер отдельно от данных и сайд-эффектов) по-прежнему верна, но теперь граница обычно проходит между кастомным хуком и компонентом, который рендерит, а не между двумя слоями компонентов. Дэн Абрамов, популяризовавший паттерн, перестал советовать его как правило в 2019 году.
Как было: контейнер загружал данные и хранил стейт; презентационный компонент получал всё через пропсы и рендерил. UI становился переиспользуемым, его легко тестировать на фейковых данных.
Что изменилось: с хуками любой компонент может напрямую вызвать useQuery или хук стора, поэтому обёртка, которая только прокидывает пропсы, добавляет слой без пользы.
Где разделение ещё окупается: компоненты дизайн-системы (пропсы на вход, события на выход), компоненты в Storybook с фейковыми данными, представления, которые получают данные из двух разных источников.
Современная форма: useOrders() отвечает за загрузку и мутации, OrdersTable принимает orders и onCancel пропсами, а страница связывает их.
Server Components возвращают версию этого паттерна: серверный компонент загружает данные, клиентский обрабатывает взаимодействие.
ReactSenior
Паттерны и архитектура
Паттерны композиции, compound components, render props и HOC, error boundaries, порталы, структура папок, дизайн-системы, монорепозитории
Закрыто вопросов
0 / 20
- 0Повторить
- 6Часто спрашивают
Вопросы
SOLID придумали для классов, но у каждого правила есть React-версия: одна причина для изменения у компонента или хука, расширение через композицию вместо новых флагов, обёртки, которые сохраняют контракт того, что оборачивают, узкие пропсы и зависимости, переданные снаружи, а не импортированные.
S, единственная ответственность: у компонента, который загружает данные, преобразует их и рендерит, три причины для изменения. Данные и эффекты стоит вынести в кастомный хук (useUser), а компоненту оставить разметку.
O, открытость/закрытость: расширяйте компонент через children, слоты и render props, а не добавляйте showIcon, isCompact и variant2 под каждый новый случай.
L, подстановка Лисков: Button, который оборачивает <button>, принимает все его пропсы (ComponentProps<'button'>), прокидывает остальные дальше и передаёт ref, поэтому может заменить нативную кнопку где угодно.
I, разделение интерфейсов: передавайте name и avatarUrl, а не весь user: memo на таком компоненте не реагирует на изменения полей, которые он не читает.
D, инверсия зависимостей: компоненты получают колбэки и сервисы из контекста, а не импортируют конкретный модуль API, поэтому в тестах и Storybook их можно подменить.
Предел: механическое применение правил даёт десятки компонентов по три строки; делите компонент, когда его часть меняется по другой причине, а не по числу строк.
В React нет DI-контейнера: зависимости внедряют через пропсы, контекст и систему модулей. Для сервиса вроде API-клиента опишите интерфейс, отдайте настоящую реализацию через контекст в корне приложения, а фейковую в тестах и сторис, и пусть компоненты читают её хуком useApi().
Пропсы: самый явный способ, подходит для колбэка или сервиса на уровень ниже, но становится шумным, когда его прокидывает каждый слой.
Контекст: обычный выбор для сервисов уровня приложения (API-клиент, аналитика, фича-флаги, часы). Создайте сервис один раз, на уровне модуля или через useState(() => createApi()), чтобы значение провайдера не менялось и не ререндерило потребителей.
Мок модуля (vi.mock('./api')): в приложении не нужно лишнего кода, но мок глобален для тестового файла, привязан к пути импорта и недоступен в Storybook.
Уровень сети: для обычного HTTP внедрение часто не нужно благодаря MSW: работает настоящий клиент, а фейковые только ответы. Внедряйте, когда зависимость не HTTP (WebSocket, платёжный SDK, текущее время) или когда у клиента есть своя логика, которую надо подменить.
Бросайте ошибку из useApi() вне провайдера, а не откатывайтесь на настоящий клиент, иначе тест без провайдера незаметно пойдёт в настоящий бэкенд.
Сначала перестроить дерево: передавать готовые элементы через children или слоты, чтобы промежуточные компоненты не прокидывали ненужные им пропсы. Контекст нужен для значений, которые читают многие далёкие компоненты, а стор только для часто меняющегося состояния всего приложения.
Сначала композиция: при <Layout sidebar={<UserMenu user={user} />}> компонент Layout вообще не видит user. Часто это полностью убирает drilling.
Колокация стейта: состояние, которое использует одно поддерево, живёт наверху этого поддерева, а не в корне приложения.
Контекст для темы, локали, текущего пользователя, состояния формы: значений, которые читают во многих местах и меняют редко. При смене значения ререндерится каждый потребитель, поэтому контексты стоит делить по частоте изменений.
Стор (Zustand, Redux Toolkit), когда многие несвязанные части читают и пишут одни и те же меняющиеся данные и нужны селекторы.
Прокинуть проп на два-три уровня вниз нормально: это явно и легко отследить.
Compound components представляют собой набор компонентов, которые работают только вместе и делят неявное состояние через контекст родителя, например <Tabs>, <Tabs.Tab> и <Tabs.Panel>. Вызывающий код свободно собирает разметку, а состоянием владеет родитель.
Зачем: вместо одного компонента с конфигом tabs={[...]} и десятком опций рендера вызывающий код сам расставляет части, оборачивает их и добавляет свой контент.
Как: родитель хранит стейт и отдаёт его через createContext; каждый дочерний компонент читает его через use(Context) или useContext. В React 19 сам контекст рендерится как провайдер.
Защита: дочерний компонент вне родителя бросает понятную ошибку, а не молча читает значение по умолчанию.
Доступность: родитель ещё и генерирует id через useId, чтобы связать aria-controls и aria-labelledby.
Так устроены Radix UI, React Aria и Headless UI. Старый подход через React.Children.map и cloneElement ломается, как только дочерний элемент обёрнут во фрагмент или другой компонент.
Render prop: проп-функция, которую компонент вызывает со своим внутренним состоянием, чтобы узнать, что рендерить, например renderItem={(item) => ...}. Логику теперь переиспользуют через кастомные хуки, но render prop уместен, когда разметкой владеет компонент, а снаружи настраивают одну её часть.
История: до хуков общую логику со стейтом передавали так: <Mouse>{({ x, y }) => ...}</Mouse>, ценой глубокой вложенности.
Только логика: кастомный хук вроде useMousePosition() проще, не добавляет компонент в дерево и комбинируется без вложенности.
Где ещё полезен: renderItem в виртуализированном списке, renderOption в комбобоксе, рендерер ячейки таблицы. Компонент управляет структурой DOM, фокусом и клавиатурой; вызывающий код управляет одним слотом.
Цена: инлайн-функция на каждом рендере становится новым значением, поэтому дочерний компонент в memo ререндерится. React Compiler мемоизирует её сам; без компилятора используйте useCallback там, где это важно.
HOC (higher-order component): функция, которая принимает компонент и возвращает новый с дополнительными пропсами или поведением, например withAuth(Page) или старый connect из Redux. Большинство HOC заменили хуки, потому что HOC прячут источник пропсов и складываются в глубокие деревья обёрток.
Проблемы: коллизии имён пропсов между двумя HOC; непонятно, какая обёртка добавила проп; ref и статические члены приходится пробрасывать вручную; типизировать добавленные пропсы в TypeScript неудобно; каждый HOC добавляет уровень в DevTools.
Правила, если пишете свой: создавайте его на уровне модуля, никогда внутри рендера; пробрасывайте остальные пропсы через {...props}; задавайте displayName, чтобы DevTools показывал withAuth(Settings).
Где ещё уместны: сквозные обёртки, которые рендерят что-то вокруг компонента независимо от его логики: проверка авторизации, error boundary (withErrorBoundary), фича-флаги. memo и observer из MobX тоже являются HOC.
Вместо них: кастомный хук для логики (useAuth()), компонент-обёртка для раскладки.
Headless-компонент или библиотека даёт поведение без разметки и стилей: состояние, работу с клавиатурой, управление фокусом и ARIA-связи, а каждый элемент рендерите и стилизуете вы. Вы получаете доступность, которую трудно сделать правильно самому, и полностью контролируете дизайн.
Две формы: хуки, которые возвращают состояние и prop getters (useReactTable в TanStack Table, useButton в React Aria, Downshift), или нестилизованные компоненты (Radix Primitives, React Aria Components, Headless UI, Base UI), которые рендерят минимум DOM и отдают состояние атрибутами вроде data-state="open" для стилей.
Что вы получаете: паттерны WAI-ARIA для combobox, меню, диалога и табов, проверенные на скринридерах: roving focus, переход по первым буквам, Esc и возврат фокуса.
Почему не готовый UI-кит (MUI, Ant Design): перекрашивать чужую дизайн-систему значит бороться с её CSS и специфичностью; headless-основа с самого начала принимает ваши токены.
Цена: вы сами пишете все стили, собираете каждый виджет и следите за единообразием приложения. Поэтому команды оборачивают примитивы в компоненты своей дизайн-системы; shadcn/ui делает то же самое: копирует в ваш репозиторий компоненты на основе Radix, стилизованные Tailwind.
Error boundary ловит ошибки, брошенные при рендере его поддерева (и в методах жизненного цикла), и показывает fallback вместо размонтирования всего приложения. Он не ловит ошибки в обработчиках событий, в асинхронном коде, при серверном рендере и в самом себе.
Всё ещё класс: границей может быть только класс со static getDerivedStateFromError (переключиться на fallback) и/или componentDidCatch (залогировать); хука для этого нет. Большинство команд берут react-error-boundary, который оборачивает этот класс.
Зачем нужен: с React 16 непойманная ошибка рендера размонтирует весь корень, потому что пустая страница безопаснее испорченного UI. Граница ограничивает ущерб одним виджетом.
Что не ловится: обработчики событий выполняются вне рендера, поэтому там нужен try/catch. Асинхронную ошибку сохраняют в стейт и бросают через throw во время рендера или вызывают showBoundary из react-error-boundary.
React 19: ошибка внутри колбэка startTransition или Action формы доходит до ближайшей границы, а createRoot принимает onCaughtError и onUncaughtError для отправки отчётов.
Error boundary ловит ошибки, брошенные во время рендера, а обработчик события (тем более промис, который отклоняется позже) выполняется вне рендера, поэтому reject уходит в unhandledrejection, а UI продолжает работать как ни в чём не бывало.
React вызывает getDerivedStateFromError у границы, только если компонент бросает ошибку, пока React его рендерит или коммитит. handleClick выполняется позже, из события браузера, и его отклонённый промис никак не связан с деревом.
Исправление 1: поймать ошибку и показать её рядом с кнопкой. Обычно это лучше для UX, чем заменять весь виджет на fallback.
Исправление 2: если сбой должен «уронить» секцию, бросить ошибку заново во время рендера: сохранить её в стейт и сделать throw, как в исправленном коде, или вызвать showBoundary(error) из useErrorBoundary в react-error-boundary.
Исправление 3 (React 19): выполнить сохранение внутри startTransition; ошибка в асинхронной функции перехода уходит в ближайшую границу.
createPortal(children, domNode) рендерит дочерние элементы в другой DOM-узел (обычно в конец document.body), при этом в дереве React они остаются на своём месте. Портал нужен для модалок, тултипов, поповеров и тостов, которым надо вырваться из overflow: hidden или контекста наложения родителя.
Что остаётся от дерева React: контекст, стейт и всплытие событий идут по родителю в React, а не в DOM. Модалка внутри ThemeProvider всё равно получает тему.
**Почему не хватает position: fixed**: предок с transform или filter делает fixed относительным себе, overflow: hidden обрезает, а никакой z-index не выходит за контекст наложения родителя.
Нативная альтернатива: <dialog>, открытый через showModal(), и Popover API рендерятся в top layer браузера поверх всего без портала и сами обрабатывают Esc и фокус.
Доступность: портал только переносит DOM. Ловушка фокуса, aria-modal, возврат фокуса и inert на фоне остаются вашей задачей или задачей библиотеки.
SSR: на сервере нет document, поэтому портал создают после монтирования компонента.
Выведется button, затем App div: React распространяет свои события по дереву React, а там содержимое портала является потомком div, хотя в DOM кнопка лежит прямо в document.body.
React слушает события на корневом контейнере (и на каждом контейнере портала) и рассылает синтетические события, обходя дерево компонентов, поэтому для onClick, onKeyDown, onFocus и остальных портал ведёт себя как обычный потомок.
Нативный addEventListener('click') на этом DOM-элементе div не сработал бы, потому что в DOM кнопка не внутри него.
Поэтому клик внутри модалки может вызвать внешний onClick, который, например, закрывает дропдаун. Остановить это можно через e.stopPropagation() в корне портала или сравнив e.target с контейнером.
Контекст подчиняется тому же правилу: портал читает провайдеры у своих предков в React.
Группировать код по фичам, а не по типу файлов: features/checkout держит вместе свои компоненты, хуки, запросы к API и тесты, а shared/ хранит общий UI и утилиты, которые ничего не знают о фичах. Тогда изменение затрагивает одну папку, а удаление фичи сводится к удалению одной папки.
По типу (components/, hooks/, services/) подходит для маленького приложения, но при нескольких сотнях файлов одна фича размазана по пяти папкам, и никто не знает, что можно безопасно удалить.
Слои: app/ (провайдеры, роутер, глобальные стили), pages/ или routes/ (тонкие, собирают фичи), features/ (бизнес-логика и её UI), shared/ (дизайн-система, API-клиент, хелперы).
Направление зависимостей: страницы импортируют фичи, фичи импортируют shared, никогда наоборот; фича не лезет во внутренности другой фичи.
Публичный API: каждая фича отдаёт наружу index.ts; всё остальное в папке приватно.
Feature-Sliced Design формализует это фиксированными слоями и правилами импорта; его стоит брать, когда команде нужен строгий общий стандарт.
Колокация: тесты, стили и stories лежат рядом с компонентом. В Next.js App Router папка app/ задаёт дерево роутинга, поэтому фичи живут вне её.
Называйте проблемы по степени влияния: сначала баги (эффект игнорирует teamId, не делает cleanup и доверяет любому ответу), потом производное состояние в эффекте, потом доступность и только затем нейминг и стиль. Помечайте каждый комментарий как блокирующий или nit и предлагайте исправление.
Блокирует, устаревшие данные: эффект читает teamId, но массив зависимостей пустой ([]), поэтому после смены команды на экране остаётся первая команда. Это ловит react-hooks/exhaustive-deps.
Блокирует, гонка: когда teamId попадёт в зависимости, медленный ответ для предыдущей команды может прийти последним и перезаписать текущий. Cleanup должен его игнорировать или отменять запрос.
Блокирует, ошибки: на ответ с ошибкой res.json() либо отклоняется без обработки, либо кладёт тело ошибки в users, и тогда users.filter роняет рендер. Проверяйте res.ok и показывайте состояние ошибки; состояния загрузки тоже нет.
Стоит исправить, производное состояние: visible вычисляется из users и query; хранение в стейте стоит лишнего рендера на каждое изменение и может отрисовать список, отстающий на одно обновление. Вычисляйте его во время рендера.
Стоит исправить, доступность: на кликабельный div нельзя попасть с клавиатуры, а у поля ввода нет подписи. Нужны <button> и aria-label или <label>.
Мелочи (nits): фильтр чувствителен к регистру.
Подачу тоже оценивают: объясняйте, почему пункт важен, предлагайте код вместо «так неправильно» и задавайте вопрос, если намерение автора неясно.
Следовать контракту нативного инпута: принимать value с onChange для контролируемого режима и defaultValue для неконтролируемого, держать внутренний стейт только пока value равен undefined и вызывать onChange в обоих режимах. Хук useControllableState держит эту логику в одном месте.
Режим выбирается один раз: он фиксируется при монтировании. Переход от undefined к значению позже считается багом; React предупреждает об этом для нативных инпутов, и ваш компонент тоже должен предупреждать в development.
Всегда уведомлять: onChange срабатывает в обоих режимах, поэтому неконтролируемый вызывающий код тоже может слушать изменения.
Привычные имена: open/defaultOpen/onOpenChange, value/defaultValue/onValueChange. Radix и React Aria используют именно их, так что пользователи их уже знают.
Зачем оба режима: контролируемый нужен, когда родитель синхронизирует значение (URL, библиотека форм, валидация); неконтролируемый требует меньше кода в частом случае.
Антипаттерн: копировать проп в стейт через useState(props.value) и эффект, который их синхронизирует. Два источника правды расходятся.
Одну в корне как последний рубеж, по одной на роут, чтобы сломанная страница не убивала навигацию, и вокруг каждого виджета, который может упасть сам по себе (график, блок комментариев). Каждую стоит ставить в паре с Suspense на том же уровне и давать ей способ сброса.
Гранулярность
Слишком мало границ, и один упавший виджет обнуляет всю страницу. Слишком много, и страница превращается в лоскутное одеяло из мелких сообщений об ошибках, каждое из которых надо дизайнить.
Обычное решение по умолчанию: уровень роута. Экспорт ErrorBoundary у роута в React Router и файл error.tsx в Next.js дают по одной границе на сегмент.
Вместе с Suspense
Suspense отвечает за «ещё не готово», error boundary за «упало». Их вкладывают парой, ErrorBoundary снаружи и Suspense внутри, чтобы отклонённый промис, прочитанный через use() или useSuspenseQuery, показывал ошибку там же, где было состояние загрузки.
Сброс
Показав fallback, граница остаётся в состоянии ошибки до сброса. react-error-boundary даёт resetErrorBoundary и resetKeys (например, сброс при смене пути); смена key у границы делает то же через перемонтирование.
Сбрасывать нужно и причину: QueryErrorResetBoundary из TanStack Query очищает упавшие запросы, чтобы повтор действительно перезапросил данные.
Отчёты
createRoot(el, { onCaughtError, onUncaughtError, onRecoverableError }) в React 19 даёт одно место, чтобы отправлять все ошибки в Sentry, вместо componentDidCatch в каждой границе.
Ловите ошибки из всех источников, привязывайте каждый отчёт к вашему коду и релизу, отсекайте шум и шлите алерты о новом и растущем, а не о каждом событии. Инструмент вроде Sentry берёт на себя доставку; будет ли кто-то читать отчёты, решает настройка.
Ловить всё
Опции корня в React 19 onCaughtError, onUncaughtError и onRecoverableError (Sentry оборачивает их через Sentry.reactErrorHandler()) видят ошибки рендера; хуки SDK на window.onerror и unhandledrejection видят обработчики событий и асинхронный код.
Упавший запрос не является исключением: сообщайте о 5xx и сетевых сбоях один раз, в API-клиенте, группируя по шаблону роута, а не по URL с id внутри.
Сделать читаемым
Загружайте source maps каждой сборки из CI и не отдавайте их публично, тогда стектрейсы показывают ваши файлы и строки.
release с SHA коммита позволяет инструменту отмечать ошибки как новые в релизе, ловить регрессии уже закрытых и указывать на подозрительные коммиты; environment отделяет стейджинг.
Контекст: id пользователя (не email), роут, фича-флаги и breadcrumbs: клики, переходы и запросы перед падением.
Убрать шум
Отбрасывайте ошибки из расширений браузера и сторонних скриптов (denyUrls, голый Script error. от скриптов с другого origin) и известные безвредные, вроде предупреждения о цикле ResizeObserver.
Если один баг распадается на сотни ошибок, потому что в сообщении есть id, задайте свой fingerprint.
Вычищайте токены и персональные данные в beforeSend.
Действовать
Алерт на новую ошибку, регрессию или всплеск доли ошибок после деплоя, адресованный команде, которая владеет кодом.
Во время поэтапной раскатки следите за долей сессий без падений по релизу: это сигнал для отката.
Дать каждой фиче одну публичную точку входа (features/cart/index.ts) и закрепить это правилом линтера, которое запрещает глубокие импорты вроде features/cart/ui/CartRow. Тогда структура папок становится контрактом, который проверяет CI, а не договорённостью, о которой забывают.
Как закрепить: no-restricted-imports с паттернами, eslint-plugin-boundaries для правил слоёв или @nx/enforce-module-boundaries в монорепозитории на Nx.
Подвох 1, циклы: две фичи, импортирующие index.ts друг друга, образуют циклический импорт, и значение может оказаться undefined в момент, когда модуль его читает. Цикл означает, что общая часть должна жить в shared/ или слое сущностей.
Подвох 2, загрузка всего подряд: импорт одного имени из большого barrel с export * заставляет dev-сервер, тесты, а иногда и бандл загрузить все модули за ним. Tree-shaking выбрасывает остальное, только если модули без сайд-эффектов ("sideEffects": false). Не стоит делать один огромный barrel для большой библиотеки вроде набора иконок.
Общение между фичами идёт через страницу, которая их собирает, общую сущность или событие, а не через прямые импорты.
Монорепозиторий окупается, когда несколько приложений делят код (дизайн-систему, API-клиент, конфиги) и нужно одним PR поменять библиотеку и всех её потребителей сразу. Обычная связка: pnpm workspaces плюс Turborepo или Nx для кешируемой сборки только того, что изменилось.
Плюсы: атомарные изменения сразу в нескольких пакетах, один lockfile и одна версия React, общие конфиги TypeScript, ESLint и Vitest, рефакторинг, который тайпчекер проверяет во всех приложениях.
Минусы: CI должен собирать и тестировать только затронутые пакеты (turbo run test --filter=...[origin/main], nx affected), иначе он растёт с каждым пакетом; для владения кодом нужны правила вроде CODEOWNERS.
Внутренние пакеты: "@acme/ui": "workspace:*" подключает пакет без публикации. Можно собирать каждый пакет или направить exports на исходники TypeScript и дать бандлеру приложения скомпилировать их.
Классический баг: библиотека, у которой react записан в dependencies, ставит вторую копию, и хуки падают с «Invalid hook call». Пакеты-библиотеки объявляют react как peerDependency.
Версионирование: Changesets, если пакеты ещё и публикуются; иначе всё выкатывается из main вместе.
Когда не подходит: несвязанные продукты с разными командами и циклами релизов; отдельные репозитории и опубликованный пакет проще.
Сначала перейдите на React 18.3: он предупреждает о каждом API, удалённом в React 19. Исправьте предупреждения, прогоните официальные codemod-скрипты и обновите библиотеки с peer-диапазоном до 18. Ломается старый код: legacy API корня, возможности эпохи классов, библиотеки на findDOMNode и типы.
Удалённые API
ReactDOM.render и hydrate (теперь createRoot и hydrateRoot), unmountComponentAtNode, findDOMNode, строковые refs, legacy context (contextTypes), createFactory и всё из react-dom/test-utils, кроме act, который переехал в react.
Проверки propTypes молча игнорируются, а defaultProps у функциональных компонентов больше нет, поэтому значения по умолчанию переезжают в деструктуризацию. Ничего не падает: компонент просто тихо получает undefined.
Новый JSX transform обязателен.
Библиотеки
Зависимость, у которой peerDependencies заканчиваются на ^18, блокирует установку, а форсированная установка только прячет поломку. Обновите её или замените, если она заброшена (Enzyme, react-beautiful-dnd).
Всё, что ещё вызывает findDOMNode, например старый код с react-transition-group без nodeRef, падает в рантайме.
Поведение и типы
Ошибки рендера больше не пробрасываются повторно: непойманные уходят в window.reportError, пойманные в console.error. Отчёты, которые слушают только window.onerror, не увидят ошибки, пойманные error boundary: добавьте onCaughtError на корне.
Типы: useRef() требует аргумент, ref-колбэк не может неявно возвращать значение, глобальный namespace JSX становится React.JSX, а ReactElement['props'] имеет тип unknown. Большую часть исправляет codemod для типов.
Раскатка
В приложении работает одна копия React, поэтому переход делается одним переключением: проверьте ветку с включённым StrictMode, затем раскатайте на небольшую долю пользователей и сравните долю ошибок перед раскаткой на всех.