Как проектировать масштабируемый React frontend: архитектура, состояние и границы модулей
Практический разбор того, как проектировать масштабируемый React frontend: модули, state management, performance, типичные ошибки и сильный ответ на интервью.
- Введение
- Что именно делает React frontend масштабируемым
- Базовая модель: из каких частей состоит масштабируемый frontend
- Shell и app-слой
- Route или page-слой
- Feature-слой
- Entity или domain-слой
- Data layer
- Shared-инфраструктура
- Архитектурный разбор: как проходит запрос через масштабируемую систему
- Контекст задачи
- Схема модулей
- Поток события
- Где должно жить состояние
- Локальное UI-state
- Server state
- URL state
- Infrastructure state
- Сравнение архитектурных подходов
- Public API и правила зависимостей
- Production pitfalls
- 1. Один store на все случаи жизни
- 2. Shared превращается в черный ящик
- 3. Page-компоненты знают слишком много
- 4. Server state копируют в клиентское хранилище
- 5. Архитектуру пытаются "починить" только memoization
- Разбор производительности: где масштабируемость упирается в latency
- Практики, которые удерживают архитектуру в рабочем состоянии
- Договариваться о слоях раньше, чем проект станет большим
- Держать page-слой тонким
- Делать зоны ответственности модулей явными
- Разделять чтение и изменение
- Внедрять архитектурные изменения частями
- Считать category hub частью навигации, а не только SEO
- Частые ошибки
- Как отвечать на интервью
- FAQ
- С чего начинать проектирование масштабируемого React frontend?
- Когда уже пора вводить формальную слоистую архитектуру?
- Можно ли построить масштабируемый frontend без Redux, Zustand или другой глобальной библиотеки?
- Когда микрофронтенды становятся разумным шагом?
- Как понять, что проблема уже архитектурная, а не локальная?
- Итоги
Введение
Запрос как проектировать масштабируемый React frontend почти никогда не касается структуры папок как таковой. Обычно за ним стоит другая проблема: приложение уже выросло из набора экранов и переходит в фазу, где любое новое требование цепляет маршруты, формы, кэш, права доступа, аналитику и производительность одновременно. В этот момент команда перестает спорить о стиле компонентов и начинает спорить о границах ответственности. Базовую рамку для такого роста удобно опираться на разбор React архитектуры больших приложений, потому что именно там видно, почему хаос начинается раньше, чем кажется.
Масштабируемость во фронтенде редко ломается одной большой ошибкой. Чаще сначала появляется слишком общий store, потом shared начинает знать о бизнес-логике, затем page-компоненты превращаются в оркестраторы на сотни строк, а точечные оптимизации маскируют системную проблему вместо того, чтобы ее убрать. Поэтому сильный дизайн React-приложения начинается с четырех вопросов: где проходят границы модулей, где живут разные типы состояния, как система переживает рост команды и что станет узким местом при удвоении сценариев.
В этой статье разберем, как проектировать масштабируемый React frontend без абстрактных лозунгов: какие слои действительно нужны, как раскладывать состояние, когда вводить FSD или микрофронтенды, где чаще всего ломается production и как объяснять такие решения на интервью.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью.
Что именно делает React frontend масштабируемым
Масштабируемый frontend не обязан быть сложным. Он обязан оставаться предсказуемым в трех измерениях:
- При росте продукта новые фичи не должны требовать чтения половины репозитория.
- При росте команды правила зависимостей должны быть понятны без устных договоренностей.
- При росте нагрузки и данных интерфейс не должен деградировать из-за случайно выбранной модели состояния.
На практике это означает, что масштабируемость определяется не размером кодовой базы, а стоимостью изменения. Если правка фильтра затрагивает API-клиент, три соседние фичи, глобальный store и таблицу, проблема уже архитектурная. Если модуль можно доработать локально, протестировать локально и выкатить без каскада побочных эффектов, система масштабируется нормально.
Здесь полезно мыслить не только компонентами, но и системно. Такой ракурс хорошо раскрывается в материале про system design для frontend разработчика: фронтенд тоже проектируют как систему, а не как набор JSX-файлов.
Базовая модель: из каких частей состоит масштабируемый frontend
Универсальной схемы нет, но для большинства React-приложений рабочая модель выглядит так:
appили shell-слой;- route/page-слой;
- feature-слой;
- entity или domain-слой;
- data layer;
- shared-инфраструктура.
Названия могут отличаться. Важно другое: каждый слой отвечает за свой класс решений.
Shell и app-слой
Здесь живут роутинг, провайдеры, глобальная инициализация, границы ошибок, сессия, конфигурация окружений и подключение аналитики. Этот слой не должен хранить бизнес-логику экрана. Если App.tsx начинает знать о фильтрах каталога, ролях в конкретной админке или правилах оплаты, приложение выносит доменные решения на слишком высокий уровень.
Route или page-слой
Страница собирает экран из готовых модулей и управляет композиция сценариев. Это место, где становится понятно, какие зоны интерфейса участвуют в конкретном маршруте. Но page-слой не должен быть вторым backend внутри JSX. Если файл страницы превращается в 600 строк запросов, маппинга, переключений режимов и побочных эффектов, это уже признак того, что часть логики требует выделения в отдельный модуль.
Feature-слой
Feature описывает действие пользователя, а не просто визуальный блок. Применить фильтр, отредактировать профиль, подтвердить заказ, открыть предпросмотр документа, сохранить драфт. Такое разбиение позволяет привязывать состояние и побочные эффекты к сценарию, а не размазывать их по визуальным компонентам.
Подход с явными слоями и public API хорошо формализован в Feature-Sliced Design для React проектов. Даже если вы не внедряете FSD целиком, сама идея направленных зависимостей почти всегда полезна.
Entity или domain-слой
Здесь живут доменные сущности: user, order, product, invoice. Обычно в этом слое размещают типы, адаптеры, форматирование, компактные UI-представления сущности и часть доменных инвариантов. Смысл слоя в том, чтобы общая модель предметной области не была спрятана в случайных helper-файлах.
Data layer
Этот слой отвечает за серверные запросы, кэширование, инвалидацию, адаптацию ответа сервера и правила повторной загрузки. Главное правило: server state не должен притворяться client state без явной причины. Если данные приходят с бэкенда, именно сервер остается источником истины, а клиент только управляет представлением, кэшем и пользовательскими сценариями вокруг этих данных.
Shared-инфраструктура
shared нужен для UI-kit, HTTP-клиента, базовых хуков, форматтеров, логирования, feature flags и других повторно используемых примитивов без предметной привязки. Как только там появляются скидки, заказы, биллинг или роли пользователей, слой перестает быть shared и начинает прятать доменную логику за нейтральным именем.
Архитектурный разбор: как проходит запрос через масштабируемую систему
Контекст задачи
Представим B2B-интерфейс со списком заявок:
- таблица с тысячами строк;
- фильтры и сохраненные представления;
- сайдбар деталей;
- inline-редактирование статуса;
- optimistic update;
- права доступа;
- фоновое обновление данных.
На таком экране быстро видно, выдерживает ли архитектура рост.
Схема модулей
src/
app/
providers/
router/
pages/
requests/
ui/RequestsPage.tsx
features/
request-filters/
change-request-status/
request-details-panel/
entities/
request/
shared/
api/
ui/
lib/
Такая структура полезна не сама по себе, а потому что она задает поток зависимостей: страница собирает экран, feature управляет действием, entity описывает предметную модель, shared поставляет инфраструктуру.
Поток события
- Пользователь меняет фильтр.
- Feature-модуль синхронизирует draft поля, URL state и параметры запроса.
- Query-слой формирует ключ запроса и читает кэш или уходит в сеть.
- Page получает готовые данные и раскладывает их по таблице, счетчикам и сайдбару.
- Отдельная feature запускает мутацию статуса.
- После успешной операции инвалидируются только нужные ключи, а не весь экран.
Если на любом из этих шагов смешать временные UI-флаги, серверные данные и доменную логику в один контейнер, экран станет хрупким. На малом масштабе это не видно, но при росте фич каждая новая правка начинает затрагивать слишком большую часть дерева.
Где должно жить состояние
В масштабируемом React frontend полезно сначала классифицировать состояние, а уже потом выбирать библиотеку. Обычно есть минимум пять типов:
- локальное UI-state;
- state пользовательского сценария;
- server state;
- URL state;
- инфраструктурное состояние приложения.
Локальное UI-state
isModalOpen, activeTab, draftValue, временное состояние панели, локальный hover или текущая вкладка почти всегда должны жить рядом с feature или компонентом. Поднимать их в глобальный store ради единообразия дорого. Количество затронутых компонентов, а ценности это не добавляет.
Server state
Ответы API, которые должны переиспользоваться, инвалидироваться и обновляться по правилам сети, лучше держать в отдельном серверном кэше. Если копировать их в клиентское хранилище "на всякий случай", появляются устаревшие данные, ручная синхронизация и флаги, скрывающие архитектурный долг. Подробный разбор этой границы уже есть в руководстве по state management в React.
URL state
Фильтры, сортировка, пагинация, выбранный режим представления и другие навигационные параметры часто стоит хранить в URL. Это делает refresh предсказуемым, улучшает share link и снижает скрытую связанность между экранами.
Infrastructure state
Тема, локаль, текущая сессия, feature flags, конфигурация среды и близкие к ним данные подходят для Context API или другого глобального провайдера. Но Context не стоит использовать как универсальную замену store для горячих обновлений. Эта граница хорошо объясняется в разборе когда использовать React Context API.
Ниже пример, где draft поиска живет локально, query-параметры отделены, а server state не копируется вручную:
type SearchParams = {
search: string;
status: "all" | "open" | "closed";
page: number;
};
function RequestsPage() {
const [searchDraft, setSearchDraft] = useState("");
const [params, setParams] = useState<SearchParams>({
search: "",
status: "all",
page: 1,
});
useEffect(() => {
const id = setTimeout(() => {
setParams((prev) => ({
...prev,
search: searchDraft.trim(),
page: 1,
}));
}, 250);
return () => clearTimeout(id);
}, [searchDraft]);
const query = useRequestsQuery(params);
return (
<RequestsLayout
searchDraft={searchDraft}
onSearchDraftChange={setSearchDraft}
params={params}
onParamsChange={setParams}
rows={query.data?.items ?? []}
total={query.data?.total ?? 0}
isLoading={query.isFetching}
/>
);
}
Здесь видно главное: разные типы состояния решают разные задачи. Масштабируемость появляется не из-за конкретного хука, а из-за того, что источник истины остается понятным.
Сравнение архитектурных подходов
| Подход | Где работает хорошо | Ограничения | Когда выбирать | Что ломается первым |
|---|---|---|---|---|
components/hooks/utils | Прототипы и маленькие продукты | Почти нет правил зависимостей | До появления нескольких доменов и сложных сценариев | Бизнес-логика размазывается по проекту |
| Feature-модули без явного domain-слоя | Средние приложения с понятными сценариями | Трудно отделять сущности от действий | Когда проект уже растет, но еще не требует жесткой формализации | Модули быстро становятся внутренними монолитами |
| FSD или похожая слоистая модель | Крупные React-приложения и несколько команд | Нужны дисциплина и public API | Когда важны предсказуемые зависимости и явные зоны ответственности | Формальное внедрение без реальных правил |
| Один глобальный store для всего | Короткий старт на раннем этапе | Высокая связность и лишние ререндеры | Почти никогда как долгосрочная стратегия | Любое действие будит полдерева |
| Микрофронтенды | Независимые продуктовые потоки и отдельные поставки | Дорогая интеграция и сложнее UX | Когда организационный масштаб уже больше цены интеграции | Runtime-сложность и дублирование инфраструктуры |
Таблица важна по одной причине: масштабируемый React frontend почти никогда не строится вокруг одной серебряной пули. Обычно выигрывает составная архитектура, где разные типы проблем решаются разными слоями и инструментами.
Public API и правила зависимостей
Рост проекта чаще ломает не выбор библиотеки, а отсутствие контракта между модулями. Если соседние фичи импортируют внутренние файлы друг друга, любая перестройка каталогов превращается в массовый рефакторинг.
Плохо:
import { mapRequestToRow } from "@/entities/request/model/mappers/mapRequestToRow";
import { useInternalStatusGuard } from "@/features/change-request-status/model/useInternalStatusGuard";
Лучше:
import { mapRequestToRow } from "@/entities/request";
import { ChangeRequestStatusButton } from "@/features/change-request-status";
Идея простая: модуль экспортирует наружу только то, что является его публичным контрактом. Это снижает связанность, упрощает code review и делает архитектуру проверяемой линтерами. Пока такого контракта нет, границы в проекте декоративны.
Production pitfalls
1. Один store на все случаи жизни
Симптомы:
- в store лежат и модалки, и таблицы, и ответы API, и права доступа;
- на простое действие пользователя подписано слишком много компонентов;
- даже мелкий UI-флаг вызывает каскад обновлений.
Последствия в production: рост p95 по интерактивным сценариям, сложные регрессии и дорогое сопровождение. Исправление почти всегда начинается не с новой библиотеки, а с переклассификации типов состояния.
2. Shared превращается в черный ящик
Симптомы:
- в
sharedпоявляютсяbillingUtils,orderHelpers,userPolicies; - никто не может объяснить, почему этот код общий;
- слой инфраструктуры начинает зависеть от предметной области.
Последствие: доменная логика исчезает из поля зрения, а архитектурный долг растет скрыто.
3. Page-компоненты знают слишком много
Симптомы:
- в одной странице лежат запросы, адаптеры, валидация, права доступа и локальная оркестрация;
- тестировать экран можно только через длинный интеграционный сценарий;
- любой баг требует чтения огромного контейнера.
Последствие: скорость изменений падает раньше, чем проект достигает действительно большого масштаба.
4. Server state копируют в клиентское хранилище
Симптомы:
- после каждой мутации нужны ручные патчи;
- появляются флаги
hasHydrated,needsResync,isFreshEnough; - интерфейс периодически показывает устаревшие данные после возврата на экран.
Последствие: race condition и ошибки, которые трудно воспроизвести локально.
5. Архитектуру пытаются "починить" только memoization
Если таблица перерендеривается из-за того, что состояние поднято слишком высоко, добавление memo, useMemo и useCallback может временно сгладить симптом. Но причина останется той же: неверная граница обновлений.
Разбор производительности: где масштабируемость упирается в latency
В больших React-приложениях производительность редко страдает из-за React как такового. Обычно проблема в сочетании трех решений:
- слишком широкий provider или store;
- дорогие вычисления в горячем пользовательском сценарии;
- неверная стратегия загрузки данных и кода.
Типичный пример:
function OrdersTableContainer() {
const [search, setSearch] = useState("");
const orders = useOrdersStore((state) => state.orders);
const visibleOrders = orders
.filter((order) => order.customer.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => a.customer.localeCompare(b.customer))
.map((order) => ({
...order,
formattedTotal: formatMoney(order.total),
}));
return (
<>
<SearchInput value={search} onChange={setSearch} />
<OrdersTable rows={visibleOrders} />
</>
);
}
На маленьком объеме данных это выглядит нормально. На таблице в несколько тысяч строк поиск начинает тормозить не потому, что React "медленный", а потому что вся дорогостоящая работа привязана к самому горячему действию пользователя. В такой ситуации стоит задавать вопросы в правильном порядке:
- Можно ли перенести фильтрацию на сервер?
- Нужен ли debounce?
- Не пора ли вводить виртуализацию?
- Не пересчитываем ли мы производные данные в render без причины?
- Не хранится ли слишком много в общем store?
Сам процесс поиска реальных узких мест лучше строить через профилирование, а не через ощущения. Для этого есть отдельный разбор React performance profiling. Когда же проблема уже не в CPU, а в начальной загрузке и размере бандла, в ход идут границы чанкования и code splitting с lazy loading в React.
Когда оптимизация оправдана:
- лаг воспроизводим в пользовательском сценарии;
- профилировщик показывает конкретное дорогое обновление;
- узкое место влияет на поведение интерфейса, а не только на чувство "код стал тяжелым".
Когда оптимизация преждевременна:
- профиль не снят;
- список данных маленький;
- реальная проблема может быть в сети, layout thrashing или стороннем скрипте.
Прокачай React за 7 дней
20 вопросов и разборов по React Hooks.
Практики, которые удерживают архитектуру в рабочем состоянии
Договариваться о слоях раньше, чем проект станет большим
Необязательно вводить FSD в первый день, но полезно заранее зафиксировать базовые правила: кто имеет право импортировать кого, где живет server state, что считается feature, а что domain-слоем. Такие договоренности стоят дешево на раннем этапе и очень дорого после года хаотичного роста.
Держать page-слой тонким
Страница должна собирать экран, а не содержать половину бизнес-логики приложения. Чем меньше в page-файле решений про данные и переходы состояний, тем дешевле менять экран.
Делать зоны ответственности модулей явными
Когда у сложной фичи нет владельца, она быстро становится зоной общего пользования. Отсюда появляются "временные" импорты из внутренних модулей, скрытые зависимости и конфликты в соседних ветках.
Разделять чтение и изменение
Полезная практика для сложных модулей: отделять query-модель от command-модели. Даже если это не отдельный CQRS-слой, такая рамка помогает не смешивать чтение данных, мутации и UI-сценарии в одном месте.
Внедрять архитектурные изменения частями
Большой рефакторинг "сегодня переносим весь проект в новую структуру" редко окупается. Лучше взять один перегруженный маршрут, выделить из него feature и entity, зафиксировать public API, проверить, как на это реагируют тесты и команда, и только потом тиражировать подход.
Считать category hub частью навигации, а не только SEO
Если у команды есть несколько материалов по одной теме, хаб вроде frontend-раздела блога помогает не только поиску, но и онбордингу разработчиков: из него проще выстроить маршрут чтения от базовой архитектуры к performance и state management.
Частые ошибки
- Считать масштабируемость синонимом "сложной структуры папок".
- Сначала выбирать store, а уже потом думать о типах состояния.
- Тащить доменную логику в
shared, потому что она используется в трех местах. - Поднимать локальные UI-флаги на уровень приложения.
- Делать микрофронтенды ответом на обычную проблему слабых модульных границ.
- Пытаться лечить системную связанность только
memoи точечными оптимизациями.
Как отвечать на интервью
Сильный ответ на вопрос про то, как проектировать масштабируемый React frontend, обычно строится не от названий библиотек, а от причинно-следственной логики:
- Сначала разделяю приложение по ответственности: shell, routes, features, entities, shared.
- Затем отдельно классифицирую состояние: локальное UI-state, server state, URL state, инфраструктурное состояние.
- Не даю модулям импортировать внутренности друг друга напрямую, чтобы архитектура оставалась предсказуемой.
- Для server state использую отдельный кэширующий слой, а не копирую ответы API в общий store без причины.
- Производительность оцениваю по границам обновлений и профилированию, а не по интуиции.
- Микрофронтенды рассматриваю только тогда, когда организационный масштаб уже оправдывает их цену.
Если хочется прозвучать сильнее уровня middle, полезно добавить компромисс: "Я не пытаюсь найти один универсальный паттерн. В масштабируемом React frontend обычно работает гибридная архитектура, где разные типы состояния и модулей живут по разным правилам".
Такой ответ показывает инженерное мышление лучше, чем фраза "я бы взял React Query, Zustand и FSD". Интервьюеру важнее видеть, как вы отделяете проблему модульности от проблемы сети, а проблему сети от проблемы ререндеров.
Потренируйте архитектурные React-вопросы на реальных сценариях
Практика реальных технических собеседований по React: границы модулей, state management, performance, архитектурные компромиссы и разбор сильных ответов.
FAQ
С чего начинать проектирование масштабируемого React frontend?
С определения границ ответственности и типов состояния. Пока непонятно, где заканчивается feature, где начинается domain и кто владеет server state, обсуждать библиотеки рано.
Когда уже пора вводить формальную слоистую архитектуру?
Когда кодовая база растет быстрее, чем команда успевает удерживать ее в голове: появляются длинные page-файлы, повторяющиеся сценарии, глубокие импорты и конфликты зон ответственности между командами.
Можно ли построить масштабируемый frontend без Redux, Zustand или другой глобальной библиотеки?
Да. Во многих проектах хватает локального состояния, серверного кэша, URL state и нескольких инфраструктурных context-провайдеров. Глобальный store нужен там, где действительно есть общее клиентское состояние, а не как обязательный элемент архитектуры.
Когда микрофронтенды становятся разумным шагом?
Когда у компании несколько независимых потоков поставки, разные команды с разными релизными циклами и стоимость координации в одном приложении уже выше, чем цена интеграции отдельных фронтендов.
Как понять, что проблема уже архитектурная, а не локальная?
Если одни и те же симптомы повторяются в разных местах: каскадные ререндеры, дубли логики, спорные зоны ответственности, тяжелые page-компоненты и сложные рефакторинги даже для небольшой фичи, значит проблема системная.
Итоги
Масштабируемый React frontend начинается не с красивой схемы папок и не с выбора "правильного" store. Основа здесь другая: явные границы модулей, осмысленная модель состояния, предсказуемое направление зависимостей и понимание того, где архитектурное решение важнее локальной оптимизации.
Если держать эти правила в проекте с самого начала, рост экранов и команды не превращается в бесконечную борьбу с хаосом. А если они уже нарушены, лучше лечить причину: переопределять зоны ответственности, отделять server state от UI-state, сужать границы обновлений и упрощать контракты между модулями. Именно это обычно и отличает frontend, который можно масштабировать, от frontend, который пока просто не пришёл в негодность.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью.
Автор
Lexicon Team
Читайте также
frontend
System design для frontend разработчика: как мыслить системно, а не только компонентами
Практический разбор system design для frontend разработчика: границы ответственности, данные, производительность, ошибки и сильный ответ на интервью.
frontend
React архитектура больших приложений: как не утонуть в слоях, состояниях и фичах
Практический разбор React архитектуры больших приложений: слои, feature-модули, state management, performance, ошибки и критерии выбора.
frontend
Feature-Sliced Design для React проектов: когда FSD помогает, а когда усложняет код
Практический разбор Feature-Sliced Design для React проектов: слои, public API, правила зависимостей, типичные ошибки, производительность и ответы для интервью.