Лекция 11. Глобальное состояние, DI, модульность и масштабирование
Введение
В предыдущих лекциях раздела мы разобрали слои приложения, чистую архитектуру и слой данных. Сегодня поднимаемся на уровень всего приложения: как организовать состояние, которое нужно многим экранам сразу; как «доставлять» зависимости в нужные места без жёсткой связки; как ловить ошибки на разных слоях; и как нарезать растущий проект на модули так, чтобы команда не мешала сама себе.
Сквозная идея лекции: связность (cohesion) высокая внутри модуля, связанность (coupling) низкая между модулями — все инструменты ниже помогают удерживать этот баланс по мере роста.
1. Три вида состояния: повторение и разграничение
Прежде чем выбирать инструмент, важно понять, каким состоянием мы управляем.
- Локальное состояние — живёт в одном компоненте/экране: открыт ли модал,
текст в поле ввода, текущая вкладка. Инструменты:
useState,useReducer. - Глобальное (клиентское) состояние — нужно нескольким экранам: текущий пользователь, тема оформления, корзина, язык. Об этом сегодня основной разговор.
- Серверное состояние — данные, «истина» которых находится на бэкенде: список заказов, профиль. Для него отдельный класс инструментов — TanStack Query (React Query), кеш, ретраи, инвалидация.
Типичная ошибка — складывать серверные данные в Redux/Zustand вручную: тогда приходится самому писать кеширование, ретраи и инвалидацию. Правило: серверное состояние — в Query-библиотеку, клиентское — в глобальный стор, локальное — в компонент.
2. Глобальное состояние: Context API vs Redux vs Zustand
2.1. Context API
Встроен в React. Подходит для редко меняющихся, «широковещательных» данных: тема, локаль, текущая сессия. Главная проблема — производительность при частых обновлениях: любое изменение значения контекста перерисовывает всех потребителей, даже если им нужна лишь часть данных. Селекторов «из коробки» нет.
2.2. Redux (Redux Toolkit)
Предсказуемый однонаправленный поток: action → reducer → новый стор → подписка.
Сильные стороны — строгая дисциплина, мощный DevTools (time-travel), middleware,
нормализация больших коллекций, селекторы (reselect). Цена — больше бойлерплейта
и порог входа. Redux Toolkit (RTK) этот бойлерплейт существенно сокращает.
const cartSlice = createSlice({ name: 'cart', initialState: { items: [] }, reducers: { addItem: (state, action) => { state.items.push(action.payload); }, },});2.3. Zustand
Минималистичный стор на хуках. Компоненты подписываются через селектор и перерисовываются только при изменении выбранного среза. Мало бойлерплейта, нет провайдеров-обёрток, лёгок для тестов.
const useCart = create((set) => ({ items: [], addItem: (item) => set((s) => ({ items: [...s.items, item] })),}));
// в компоненте — подписка только на нужный срез:const items = useCart((s) => s.items);2.4. Сравнительная таблица
| Критерий | Context API | Redux (RTK) | Zustand |
|---|---|---|---|
| Бойлерплейт | Низкий | Средний (RTK снижает) | Очень низкий |
| Порог входа | Низкий | Высокий | Низкий |
| Селекторы / точечные ререндеры | Нет (вручную мемоизация) | Да (reselect) | Да (встроены) |
| Производительность при частых апдейтах | Низкая | Высокая | Высокая |
| DevTools / отладка | Слабая | Отличная (time-travel) | Базовая (есть плагин) |
| Middleware / async | Нет | Да (thunk/saga) | Да (через middleware) |
| Масштаб (крупный проект) | Плохо | Отлично | Хорошо |
| Размер бандла | 0 (встроен) | Больше | Очень маленький |
2.5. Когда что выбирать
- Context API — небольшие, редко меняющиеся данные (тема, язык, сессия); прототипы; когда не хочется добавлять зависимость.
- Redux Toolkit — крупное приложение, сложные взаимосвязи состояния, потребность в строгой дисциплине и продвинутой отладке, большая команда.
- Zustand — средний проект, нужен глобальный стор без церемоний и с хорошей производительностью; «золотая середина» для большинства задач.
Важно: эти инструменты не взаимоисключающие. Часто встречается связка «TanStack Query для серверных данных + Zustand/Context для клиентского UI-состояния».
3. Навигационная архитектура (обзорно)
3.1. Разделение стеков
Навигацию удобно моделировать как граф экранов с явными маршрутами и параметрами. Ключевой приём — разделение на независимые стеки:
- Auth-stack — экраны входа, регистрации, восстановления пароля.
- App-stack — основное приложение после аутентификации (часто Tab-навигатор).
- Modal-stack — модальные окна поверх остального.
Корневой навигатор выбирает стек по состоянию сессии: пока сессия не проверена — экран загрузки (hydrate стейта), есть токен — App-stack, нет — Auth-stack. Это и есть гейтинг/guard: доступ к разделам по ролям и токенам.
function RootNavigator() { const { status } = useSession(); // 'loading' | 'authed' | 'guest' if (status === 'loading') return <SplashScreen />; return status === 'authed' ? <AppStack /> : <AuthStack />;}3.2. Глубокие ссылки (deep links)
Deep link — URL, открывающий конкретный экран приложения напрямую
(myapp://orders/42 или универсальная ссылка https://app.example.com/orders/42).
Применение: пуш-уведомления, письма, переход из браузера, шеринг.
В Expo конфигурируется через scheme в app.json и линкинг-конфиг навигатора,
где маршрутам сопоставляются URL-шаблоны. Важно: при открытии по deep link нужно
учитывать гейтинг — если пользователь не авторизован, ссылку запоминают и
доигрывают после входа.
4. Dependency Injection (DI)
4.1. Зачем
DI — это передача объекту его зависимостей извне, вместо того чтобы он создавал их сам. Выгоды:
- Тестируемость — в тестах подставляем mock-реализации.
- Конфигурация окружений — dev/staging/prod собираются разными зависимостями.
- Слабая связанность — верхние слои не знают конкретные классы, только абстракции.
4.2. Инверсия зависимостей (буква D из SOLID)
Принцип: верхние слои зависят от абстракций, а не от деталей. В Clean
Architecture интерфейс TodoRepository объявлен в Domain, а реализация
HttpTodoRepository живёт в Data. Use-case работает с интерфейсом и не знает,
откуда берутся данные — из сети, кеша или мока.
4.3. Способы DI в React Native / Expo
- Через пропсы — самый явный способ: компонент/ViewModel получает зависимость параметром. Прост, но «прокидывание» через много уровней утомляет (prop drilling).
- Через контекст — зависимости кладут в Context на уровне приложения, потребители достают через хук. Удобно для общих сервисов (логгер, API-клиент).
- Через контейнер (composition root) — единая точка сборки графа зависимостей; создаётся один раз при старте и раздаёт готовые объекты.
export function createContainer(env) { const http = createHttpClient(env); const todoRepo = new HttpTodoRepository(http); return { http, todoRepo };}// далее контейнер раздаём потребителям через Context + хук useDI().Тяжёлые DI-фреймворки в RN обычно избыточны: фабрики + контекст покрывают большинство случаев.
5. Обработка ошибок по слоям
Ошибки нужно ловить там, где их можно осмысленно обработать. Это требует классификации и распределения ответственности по слоям.
5.1. Классификация ошибок
- Сетевые/инфраструктурные — таймаут, нет интернета, 5xx.
- Доменные/бизнес — недостаточно средств, товар недоступен, неверный код.
- Валидационные — некорректный ввод пользователя.
- Программные (баги) — null-ссылки, нарушенные инварианты; их не «обрабатывают», а логируют и чинят.
5.2. Где ловить
- Data — маппинг низкоуровневых сетевых ошибок в типизированные доменные.
Наружу слоя не должны протекать
AxiosErrorи HTTP-коды. - Domain — решение «повторить / отложить / вернуть значение по умолчанию».
- Presentation — понятное пользователю сообщение, кнопки retry/refresh, индикация состояния.
5.3. Контракты результата и Error Boundaries
Полезно возвращать явный результат (Ok/Err) вместо «бросать исключение из
глубины без причины» — поток ошибок становится виден в типах.
Error Boundary — компонент React, который перехватывает ошибки рендера в поддереве и показывает запасной UI вместо «белого экрана». Он ловит ошибки рендеринга, но не ловит асинхронные ошибки в обработчиках и эффектах — их обрабатывают вручную. Хорошая практика: оборачивать крупные участки (экран, фичу) в Error Boundary, чтобы сбой одной части не ронял всё приложение.
<ErrorBoundary fallback={<ErrorScreen />}> <OrdersScreen /></ErrorBoundary>6. Модульность: feature-модули
6.1. Что это
Feature-модуль — это директория, в которой собрана одна крупная функция целиком: свои экраны (presentation), своя логика (domain), свой доступ к данным (data). Модуль самодостаточен и общается с внешним миром через узкий публичный интерфейс, а не через «внутренности».
6.2. Когда выделять
Признаки, что пора нарезать на feature-модули:
- Папки
screens/,components/разрослись, файлы тяжело найти. - Несколько человек регулярно конфликтуют в одних и тех же файлах.
- Появились явные «владельцы» данных (профиль, платежи, каталог) — bounded contexts.
- Хочется собирать/тестировать части независимо, ускорить ревью.
Не стоит делать преждевременно: для маленького приложения деление по слоям (domain/data/presentation) достаточно, фиче-модули добавят накладные расходы.
6.3. Пример структуры проекта по фичам
src/ app/ di/ # сборка зависимостей (composition root) navigation/ # корневой навигатор, разделение стеков config/ # окружения, константы shared/ ui/ # общие компоненты (кнопки, инпуты) utils/ # утилиты, хелперы services/ # общие сервисы (логгер, http-клиент) features/ auth/ domain/ # сущности, use-cases, интерфейсы репозиториев data/ # реализации репозиториев, источники, мапперы presentation/ # экраны, ViewModel-хуки, навигация фичи index.ts # публичный API модуля orders/ # та же структура: domain / data / presentation / index.ts profile/ # ...Правила:
- Фичи общаются через интерфейсы/события, а не лезут друг другу внутрь.
sharedостаётся небольшим и стабильным — иначе он превращается в свалку.- Зависимости направлены внутрь: presentation → domain, data → domain (через интерфейсы).
7. Понятия про запас: DDD, GRASP, ADR
Эти подходы помогают принимать и фиксировать архитектурные решения. На уровне мобильного приложения знать их полезно даже без полного внедрения.
7.1. DDD — Domain-Driven Design
Проектирование от предметной области. Ключевые элементы:
- Entity — объект с идентичностью (Заказ #42).
- Value Object — объект без идентичности, важна только ценность (Деньги, Адрес).
- Aggregate (Root) — группа связанных объектов с единой точкой входа и инвариантами.
- Bounded Context — граница, внутри которой термины модели однозначны; интеграция контекстов через контракты/anti-corruption layer.
- Ubiquitous Language — единый словарь, общий для разработчиков и бизнеса.
В мобильной архитектуре цель — тонкий, стабильный Domain, чтобы изменения инфраструктуры (сеть, БД, UI) не задевали бизнес-правила.
7.2. GRASP — распределение ответственности
Набор принципов, кому какую ответственность давать: Information Expert, Creator, Controller, Low Coupling, High Cohesion, Polymorphism, Pure Fabrication, Indirection, Protected Variations. Цель та же: низкая связанность, высокая связность, предсказуемые места изменений.
7.3. ADR — Architectural Decision Records
Короткий документ, фиксирующий одно значимое архитектурное решение. Шаблон: Контекст → Решение → Последствия → Альтернативы. Зачем нужны: прозрачность, быстрый онбординг новых людей, снижение «bus factor» и архитектурного долга. Пример темы ADR: «Выбрали Zustand вместо Redux, потому что…».
Краткие итоги
- Состояние делится на локальное, глобальное (клиентское) и серверное — для каждого свой инструмент; серверное держим в TanStack Query, не в Redux вручную.
- Context — для редких/широковещательных данных; Redux — для крупного масштаба и строгой отладки; Zustand — лёгкая «золотая середина» с точечными ререндерами.
- Навигацию строят как граф с разделением стеков (auth/app/modal) и гейтингом; deep links открывают экраны напрямую и должны уважать аутентификацию.
- DI = передача зависимостей извне (пропсы, контекст, контейнер) ради тестируемости и слабой связанности; верх зависит от абстракций (инверсия).
- Ошибки классифицируют и ловят на «своём» слое; Error Boundary спасает рендер, но не асинхронные ошибки.
- Feature-модули выделяют, когда проект и команда растут;
sharedдержим тонким. - DDD, GRASP, ADR — словарь и инструменты для осознанных, зафиксированных решений.
Вопросы для самопроверки
- Чем серверное состояние отличается от глобального клиентского, и почему не стоит хранить ответы API вручную в Redux/Zustand?
- Сравните Context API, Redux и Zustand по бойлерплейту, производительности при частых обновлениях и масштабу — в каких ситуациях вы выберете каждый?
- Что такое инверсия зависимостей и какими способами реализуют DI в React Native? Где должны жить интерфейсы репозиториев?
- Как распределяется обработка ошибок по слоям приложения, что ловит (и что не ловит) Error Boundary, и по каким признакам пора выделять feature-модули?