Перейти к содержимому

Лекция 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 APIRedux (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 />;
}

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

  1. Через пропсы — самый явный способ: компонент/ViewModel получает зависимость параметром. Прост, но «прокидывание» через много уровней утомляет (prop drilling).
  2. Через контекст — зависимости кладут в Context на уровне приложения, потребители достают через хук. Удобно для общих сервисов (логгер, API-клиент).
  3. Через контейнер (composition root) — единая точка сборки графа зависимостей; создаётся один раз при старте и раздаёт готовые объекты.
app/di/container.ts
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 — словарь и инструменты для осознанных, зафиксированных решений.

Вопросы для самопроверки

  1. Чем серверное состояние отличается от глобального клиентского, и почему не стоит хранить ответы API вручную в Redux/Zustand?
  2. Сравните Context API, Redux и Zustand по бойлерплейту, производительности при частых обновлениях и масштабу — в каких ситуациях вы выберете каждый?
  3. Что такое инверсия зависимостей и какими способами реализуют DI в React Native? Где должны жить интерфейсы репозиториев?
  4. Как распределяется обработка ошибок по слоям приложения, что ловит (и что не ловит) Error Boundary, и по каким признакам пора выделять feature-модули?