React архитектура больших приложений: как не утонуть в слоях, состояниях и фичах
Практический разбор React архитектуры больших приложений: слои, feature-модули, state management, performance, ошибки и критерии выбора.
- Введение
- Что ломается первым, когда приложение становится большим
- Базовая модель: из каких слоев состоит большое React-приложение
- Роли слоев
- Архитектурный разбор: как проходит запрос через систему
- Контекст задачи
- Схема компонентов и ролей
- Поток события
- Узкие места и точка отказа
- Где должно жить состояние в большом приложении
- Сравнение подходов для больших React-приложений
- Production pitfalls
- 1. Фича импортирует фичу
- 2. Один store на все случаи жизни
- 3. Shared превращается в черный ящик
- Разбор производительности: где архитектура бьет по latency
- Практики, которые удерживают архитектуру в рабочем состоянии
- Явные правила зависимостей
- Feature ownership
- Тонкий page-слой
- Разделение read и write
- Rollout и rollback
- Частые ошибки
- Как отвечать на интервью
- FAQ
- Нужен ли FSD или другой формальный паттерн, чтобы приложение считалось архитектурным?
- Когда уже пора выносить логику из pages в features?
- Можно ли держать entity и feature в одном модуле, если команда маленькая?
- Микрофронтенды решают проблему масштаба автоматически?
- Как понять, что архитектура стала тормозить команду?
- Итоги
Введение
Пока React-приложение состоит из пяти экранов и одной команды, архитектура кажется вторичной. Есть components, pages, пара хуков, один store, и все работает. Проблемы начинаются не на старте, а в момент роста: появляется несколько продуктовых направлений, десятки сценариев, сложные формы, server state, права доступа, A/B-логика, аналитика, фоновые обновления, частичные рефетчи и требования к скорости разработки. В этот момент вопрос уже не в том, "какую папку создать", а в том, как удержать систему от расползания.
Если смотреть шире, React архитектура больших приложений складывается из пяти решений: где проходит граница фичи, где живет состояние, как слои зависят друг от друга, как изолируются побочные эффекты и как команда предотвращает хаос в кодовой базе из‑за быстрых локальных решений. Без этой рамки даже хороший state management начинает работать против проекта. На эту тему полезно дополнить изучением материалов про state management в React и React performance profiling, потому что архитектурные ошибки почти всегда всплывают либо в связности кода, либо в лишних обновлениях дерева.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью.
Что ломается первым, когда приложение становится большим
У большого React-приложения редко бывает одна большая архитектурная ошибка. Обычно ломается связка из нескольких слабых решений:
- компонентный слой знает слишком много об API;
- фичи импортируют друг друга напрямую;
- server state копируется в клиентское хранилище;
- UI-состояние поднимают слишком высоко;
- общие компоненты начинают тянуть доменную логику;
- папка
sharedпревращается в свалку всего непонятного.
На короткой дистанции это выглядит безобидно. Команда закрывает задачу, экран работает, тесты проходят. Но потом любая новая фича требует ходить через полпроекта, изменения в одной зоне ломают три соседние, а оптимизация превращается в охоту за симптомами. Если в проекте уже приходится отдельно обсуждать "кто имеет право импортировать этот модуль" или "почему список перерендеривается от открытия модалки", архитектура стала реальной проблемой, требующей решения.
Базовая модель: из каких слоев состоит большое React-приложение
Универсальной схемы нет, но рабочая модель для большинства крупных приложений выглядит так:
appили shell-слой.processesили сценарии верхнего уровня, если они выделены явно.pagesили route-модули.featuresс пользовательскими действиями.entitiesили доменные сущности.sharedдля инфраструктурных примитивов.
Смысл не в названиях папок, а в направленность зависимостей. Верхние слои собирают экран из нижних, но не наоборот. shared не должен зависеть от конкретной бизнес-фичи, entity не должна напрямую обращаться к page, а feature не должна иметь зависимость от другой feature, если общая логика на самом деле относится к домену или в сценарий верхнего уровня.
Роли слоев
app держит роутинг, провайдеры, авторизацию на уровне оболочки, инициализацию аналитики, глобальные boundary и конфигурацию. Это не место для бизнес-правил конкретного экрана.
pages собирают экран из фич и сущностей. Они знают, что именно показывать на маршруте, но не должны содержать половину бизнес-логики внутри JSX.
features описывают пользовательское действие: редактировать профиль, применить фильтр, отправить комментарий, подтвердить оплату. Именно здесь чаще всего живет координатор локального поведения.
entities описывают устойчивые доменные объекты: user, order, product, invoice. Здесь удобно держать селекторы, типы, форматирование, базовые UI-представления сущности и доменные инварианты, если они не завязаны на конкретный сценарий.
shared нужен для повторно используемых примитивов: UI-kit, HTTP-клиент, date utils, конфиг окружения, feature flags, базовые хуки, не завязанные на продуктовую область. Как только в shared попадает логика скидок, ролей или биллинга, слой перестает быть shared и начинает скрывать доменный код под нейтральным именем.
Архитектурный разбор: как проходит запрос через систему
Контекст задачи
Представим большой B2B-интерфейс: каталог заказов, фильтры, табличный список, сайдбар деталей, редактирование сущности, optimistic update, права доступа и фоновые обновления. Это типичный экран, на котором архитектурные решения сразу видны по поведению системы.
Схема компонентов и ролей
pageсобирает layout экрана и роутинг.featureуправляет фильтрами, массовыми действиями, редактированием.entityописываетOrder, ее форматирование, карточку, базовые типы и доменные селекторы.- слой data-fetching отвечает за server state, кэш, инвалидацию и повторные запросы.
- локальный UI-state живет рядом с конкретной зоной интерфейса.
Поток события
- Пользователь меняет фильтр.
- Feature-модуль обновляет URL state и локальный UI-state панели фильтров.
- Query-слой запрашивает новый список по ключу, собранному из URL и параметров.
- Page получает данные и раскладывает их в списки, пагинацию и сайдбар.
- При открытии карточки другая feature читает
entity-модель заказа и запускает сценарий редактирования. - После мутации query-слой инвалидирует нужные ключи, а не весь экран целиком.
Если в этой схеме client state, server state и временные UI-флаги свалить в один store, экран начнет жить как монолит: сложно понять источник истины, сложно локализовать ререндеры и почти невозможно безопасно менять правила.
Узкие места и точка отказа
Обычно первыми болят три зоны:
- общий store, в который складывают все подряд;
- feature, которая знает про внутренности других feature;
- page, превращенная в 700 строк orchestration-кода.
Точка отказа здесь не одна библиотека, а потеря границ. Команда перестает видеть, где заканчивается сценарий, где начинается доменная модель и где действительно нужен общий слой.
Где должно жить состояние в большом приложении
Вопрос "Redux или Zustand" обычно задают слишком рано. Сначала полезнее разделить типы состояния:
- локальное UI-state;
- state сложного пользовательского сценария;
- server state;
- URL state;
- инфраструктурное состояние вроде темы, локали, текущей сессии.
Локальное UI-state почти всегда должно жить рядом с компонентом или feature. Не нужно поднимать isModalOpen, activeTab или временное состояние фильтра до глобального store только ради единообразия. Это быстро увеличивает радиус обновлений.
Server state не стоит дублировать в клиентском store без явной причины. Эту границу уже подробно видно в разборе state management в React. Когда ответ сервера копируют в общий store, появляются устаревшие данные, ручная синхронизация и флаги вроде isHydratedOnce, которые маскируют архитектурную проблему.
Context имеет смысл оставлять для редких инфраструктурных данных. Если нужно глубже понять эту границу, полезен отдельный материал про то, когда использовать React Context API. Для горячего разделяемого состояния обычно лучше работает store с селективными подписками или более локальная декомпозиция по feature-модулям.
type FiltersState = {
search: string;
statuses: string[];
};
type OrdersPageState = {
isDrawerOpen: boolean;
selectedOrderId: string | null;
filters: FiltersState;
};
function OrdersPageFeature() {
const [ui, setUi] = useState<OrdersPageState>({
isDrawerOpen: false,
selectedOrderId: null,
filters: { search: "", statuses: [] },
});
const query = useOrdersQuery(ui.filters);
return (
<OrdersLayout
filters={ui.filters}
onFiltersChange={(filters) => setUi((s) => ({ ...s, filters }))}
onSelectOrder={(id) => setUi((s) => ({ ...s, selectedOrderId: id, isDrawerOpen: true }))}
orders={query.data ?? []}
/>
);
}
Этот вариант нормален, пока состояние действительно локально для одной feature или страницы. Проблемы начинаются, когда тот же объект начинает использовать хедер, сайдбар, отдельные виджеты, сохраненные пресеты и внешние сценарии. В этот момент уже нужен не просто useState, а новая граница ответственности.
Сравнение подходов для больших React-приложений
| Подход | Где работает хорошо | Ограничения | Когда выбирать |
|---|---|---|---|
| Локальный state рядом с feature | Формы, модалки, панель фильтров, короткоживущее UI-поведение | Трудно делить между далекими частями дерева | Когда состояние не выходит за границы сценария |
| Context API | Тема, локаль, auth-shell, feature flags | Плохо переносит горячие обновления и широких подписчиков | Когда данные инфраструктурные и меняются редко |
| Store с селекторами | Долгоживущее клиентское состояние, сложные сценарии, много читателей | Нужны дисциплина модулей и правила ownership | Когда состояние разделяется между несколькими фичами |
| Отдельный server-state слой | Кэш, инвалидация, refetch, optimistic update | Требует не смешивать с client state | Когда источник истины на сервере |
| URL как источник истины | Фильтры, сортировка, пагинация, состояние навигации | Не все удобно сериализовать в адресную строку | Когда состояние должно переживать refresh и share link |
Таблица полезна не для выбора "одной правильной технологии", а для понимания, что большое приложение почти всегда использует гибрид, а не один универсальный контейнер.
Production pitfalls
1. Фича импортирует фичу
Симптомы:
- модуль оформления заказа напрямую импортирует внутренний хук корзины;
- переиспользование идет через чужие action и selector, а не через доменную границу;
- любое изменение одной фичи ломает соседнюю.
Последствие в продакшене: каскадные регрессии и рост стоимости изменений. Исправление почти всегда одно: вынести общее в entity, shared или верхний сценарий координации.
2. Один store на все случаи жизни
Симптомы:
- в store лежат и данные сервера, и UI-флаги, и состояние форм;
- на одно действие пользователя подписано слишком много компонентов;
- DevTools показывает длинный хвост обновлений на простую операцию.
Последствие: проект становится хрупким, а оптимизация уходит в бесконечное дробление селекторов без пересмотра модели состояния.
3. Shared превращается в черный ящик
Симптомы:
- там появляются
utils/order,helpers/checkout,shared/business; - никто не может объяснить, почему модуль находится именно там;
- общий слой начинает зависеть от продуктового кода.
Последствие: формально архитектура есть, фактически доменная логика просто спрятана под нейтральным названием.
Разбор производительности: где архитектура бьет по latency
В больших React-приложениях производительность чаще страдает не от "медленного React", а от неверных границ обновления:
- provider слишком широкий;
- тяжелый selector привязан к частому вводу пользователя;
- page пересобирает большой layout из-за локального изменения в панели;
- derived data считается в render без изоляции;
- server state излишне копируется и синхронизируется.
function OrdersPage() {
const [search, setSearch] = useState("");
const orders = useOrdersStore((s) => s.orders);
const filtered = orders.filter((order) =>
order.customerName.toLowerCase().includes(search.toLowerCase())
);
return (
<>
<SearchInput value={search} onChange={setSearch} />
<OrdersTable orders={filtered} />
</>
);
}
На маленьких данных это нормально. На списке в несколько тысяч строк и при дополнительных преобразованиях такой код начинает тормозить ввод, потому что дорогое вычисление привязано к самому горячему действию пользователя. Исправлять это только useMemo недостаточно. Сначала стоит спросить:
- можно ли вынести поиск на сервер;
- нужен ли debounce;
- не пора ли виртуализировать список;
- не должен ли фильтр жить в URL и запускать отдельный data-fetching сценарий.
Именно поэтому архитектурное решение часто сильнее микроправок. Подробно сам процесс поиска bottleneck можно понять, прочитав React performance profiling.
Когда оптимизация оправдана:
- есть повторяемый lag в пользовательском сценарии;
- профилирование показывает ресурсоёмкое обновление;
- узкое место влияет на p95 отклика, а не только на локальные ощущения разработчика.
Когда оптимизация преждевременна:
- дерево маленькое;
- профиль не снят;
- проблема может лежать в сети, layout или стороннем скрипте, а не в React-слое.
Прокачай React за 7 дней
20 вопросов и разборов по React Hooks.
Практики, которые удерживают архитектуру в рабочем состоянии
Явные правила зависимостей
Архитектура начинает работать только тогда, когда команда может коротко ответить, кто кого имеет право импортировать. Если правила отсутствуют, структура каталогов декоративна.
Feature ownership
У каждой фичи должен быть владелец или хотя бы понятная зона ответственности. Иначе модуль быстро превращается в место, куда все складывают "временно".
Тонкий page-слой
Page должен собирать экран, а не содержать бизнес-логику в JSX. Если page-файл растет в сотни строк, это обычно сигнал, что связующую логику пора вынести в feature или сценарный слой.
Разделение read и write
Хорошая практика для сложных модулей: отделять чтение доменных данных от команд на изменение. Это снижает связанность и делает тестирование предсказуемее.
type OrderActions = {
approve: (id: string) => Promise<void>;
cancel: (id: string) => Promise<void>;
};
type OrderQueries = {
getById: (id: string) => Order | undefined;
getVisibleIds: () => string[];
};
Даже если реализация потом будет через store, service layer или hooks, сама модель "чтение отдельно, изменение отдельно" помогает не смешивать UI и доменные переходы.
Rollout и rollback
Архитектурные изменения нельзя внедрять как чистый рефакторинг "все сразу". Лучше идти по частям:
- выделить одну перегруженную фичу;
- зафиксировать новую границу модулей;
- перенести зависимости;
- проверить ререндеры, тесты и удобство разработки команды;
- только потом повторять паттерн в других зонах.
Частые ошибки
- Отождествлять архитектуру с красивой структурой папок.
- Начинать с глобального store до классификации состояния.
- Тащить бизнес-логику в
shared, потому что "она используется в трех местах". - Копировать server state в client store без явной модели синхронизации.
- Выделять слишком крупные feature-модули, которые становятся внутренними монолитами» или просто «становятся монолитными.
- Пытаться решить архитектурную проблему только memoization и профилированием.
Как отвечать на интервью
Сильный ответ на тему React архитектуры больших приложений обычно выглядит так:
- Сначала разделяю приложение на слои по ответственности, а не по типу файлов.
- Отдельно определяю, где живет локальное UI-state, где client state, где server state и где URL state.
- Не даю feature-модулям зависеть друг от друга хаотично, иначе кодовая база быстро теряет предсказуемость.
- Context оставляю для инфраструктурных данных, а горячее разделяемое состояние выношу в более подходящий слой.
- Архитектуру оцениваю не по красоте, а по двум вопросам: насколько дешево менять фичи и насколько локально происходит обновление интерфейса.
Если хотите звучать сильнее сильнее, чем на уровне middle, добавьте компромисс: "Я не пытаюсь выбрать одну универсальную схему. Для большого React-приложения обычно работает составная архитектура, где разные типы состояния и логики живут по разным правилам".
Потренируйте архитектурные React-вопросы на реальных сценариях
Платформа помогает разбирать state management, границы модулей, performance и trade-off так, как это обсуждают на технических собеседованиях и в production-командах.
FAQ
Нужен ли FSD или другой формальный паттерн, чтобы приложение считалось архитектурным?
Нет. Формальный паттерн полезен как договоренность команды, но сам по себе не спасает. Если правила зависимостей не соблюдаются, даже самая аккуратная схема слоев быстро размывается.
Когда уже пора выносить логику из pages в features?
Когда page начинает хранить несколько независимых сценариев, множество обработчиков и условных веток, а изменения в одном блоке требуют перечитывать весь файл. Это прямой признак, что экран стал контейнером для нескольких фич.
Можно ли держать entity и feature в одном модуле, если команда маленькая?
Да, на раннем этапе это допустимо. Важно не название папки, а готовность позже разделить роли без переписывания половины проекта. Если доменная модель уже используется в нескольких сценариях, лучше отделить ее раньше.
Микрофронтенды решают проблему масштаба автоматически?
Нет. Они решают только часть организационных и deployment-задач, но добавляют стоимость интеграции, контрактов, shared-runtime и диагностики. Для многих продуктов это следующий этап, а не стартовая архитектура.
Как понять, что архитектура стала тормозить команду?
По косвенным признакам: одинаковая логика появляется в нескольких местах, запросы на слияние становятся слишком большими, любая правка затрагивает несколько несвязанных модулей, а разработчики часто спорят, где должен жить код. Это уже не вопрос вкуса, а сигнал о размытых границах.
Итоги
React архитектура больших приложений начинается не с выбора библиотеки и не с обсуждения идеальной структуры папок. Основа здесь другая: явные границы модулей, понятные правила зависимостей, раздельная модель состояния и локализация изменений. Когда эти правила есть, проект растет предсказуемо. Когда их нет, команда постоянно платит налог за старые быстрые решения.
На практике рабочая архитектура почти всегда гибридная. Локальное поведение живет рядом с feature, инфраструктурные данные не притворяются доменной логикой, server state не дублируется без необходимости, а page не становится вторым backend внутри JSX. Именно эта дисциплина, а не набор модных терминов, обычно отличает большое React-приложение, которое можно масштабировать, от большого React-приложения, которое просто еще не успело сломаться.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью.
Автор
Lexicon Team
Читайте также
frontend
System design для frontend разработчика: как мыслить системно, а не только компонентами
Практический разбор system design для frontend разработчика: границы ответственности, данные, производительность, ошибки и сильный ответ на интервью.
frontend
Feature-Sliced Design для React проектов: когда FSD помогает, а когда усложняет код
Практический разбор Feature-Sliced Design для React проектов: слои, public API, правила зависимостей, типичные ошибки, производительность и ответы для интервью.
frontend
React anti-patterns: 15 ошибок разработчиков, которые ломают поддержку и производительность
Разбираем React anti-patterns: 15 типичных ошибок в state, рендерах, эффектах и архитектуре. С примерами кода, trade-off и ответами для собеседования.