Практика 11. Практическая работа 11. Feature-модули, глобальное состояние и DI
Цели работы
- Реорганизовать структуру проекта Expo / React Native по feature-модулям,
отделив фичи (
features/auth,features/cart) от общего слоя (shared/). - Подключить лёгкий стор глобального состояния на Zustand и использовать его на двух экранах; на практике сравнить подход с Context API.
- Внедрить простую dependency injection: раздавать сервисы (например
apiClient) через Context, чтобы экраны не импортировали реализацию напрямую.
Работа продолжает раздел 2 и опирается на лекцию 11. Считаем, что у вас уже есть рабочий Expo-проект с навигацией (см. практику 10).
Коротко о теории
- Состояние бывает трёх видов: локальное (в компоненте), глобальное клиентское (нужно нескольким экранам — тема, сессия, корзина) и серверное («истина» на бэкенде). Сегодня работаем с глобальным клиентским.
- Zustand — минималистичный стор на хуках: нет провайдеров-обёрток, компонент подписывается через селектор и перерисовывается только при изменении выбранного среза. В отличие от него Context перерисовывает всех потребителей при любом изменении значения и не имеет селекторов из коробки.
- DI — передача зависимостей объекту извне, а не создание их внутри. В RN
чаще всего реализуют через Context + хук: общий сервис (логгер, http-клиент)
кладут в провайдер на уровне приложения, а экраны достают его через
useApi()и не знают конкретную реализацию. - Feature-модуль — директория, где собрана одна крупная функция целиком
(свои экраны, логика, доступ к данным) и которая общается с внешним миром через
узкий публичный
index.ts.shared/держим тонким и стабильным.
Задание
Шаг 0. Подготовка
Установите Zustand в существующий проект:
npm install zustandСоздайте каталог src/ (если его ещё нет) и переносите код туда поэтапно,
проверяя сборку после каждого шага: npx expo start.
Задание 1. Реорганизация проекта в feature-модули
Цель — перейти от плоских папок screens/, components/ к делению по фичам.
Целевое дерево каталогов:
src/ app/ di/ ServicesProvider.tsx # провайдер сервисов (DI) services.ts # сборка зависимостей (composition root) navigation/ RootNavigator.tsx # корневой навигатор, разделение стеков shared/ ui/ Button.tsx # общие компоненты (кнопки, инпуты, карточки) api/ apiClient.ts # интерфейс ApiClient + http-реализация features/ auth/ presentation/ LoginScreen.tsx model/ authStore.ts # стор сессии (Zustand) index.ts # публичный API модуля cart/ presentation/ CartScreen.tsx ProductsScreen.tsx model/ cartStore.ts # стор корзины (Zustand) index.tsПравила, которые нужно соблюсти:
- Экраны других фич импортируют модуль только через его
index.ts, а не лезут во внутренние файлы (features/cart/model/...). shared/содержит только переиспользуемое и стабильное (UI-кит, http-клиент); бизнес-логика конкретной фичи туда не попадает.- Зависимости направлены внутрь:
presentation→model→api.
Пример публичного интерфейса модуля:
export { CartScreen } from './presentation/CartScreen';export { ProductsScreen } from './presentation/ProductsScreen';export { useCart } from './model/cartStore';Задание 2. Глобальное состояние на Zustand
Создайте стор корзины и используйте его на двух экранах: на экране списка товаров (добавление) и на экране корзины (просмотр и очистка).
import { create } from 'zustand';
export type CartItem = { id: string; title: string; price: number };
type CartState = { items: CartItem[]; addItem: (item: CartItem) => void; removeItem: (id: string) => void; clear: () => void; total: () => number;};
export const useCart = create<CartState>((set, get) => ({ items: [], addItem: (item) => set((s) => ({ items: [...s.items, item] })), removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })), clear: () => set({ items: [] }), total: () => get().items.reduce((sum, i) => sum + i.price, 0),}));Экран 1 — добавление товара (подписан только на addItem):
import { View, Text, Button } from 'react-native';import { useCart } from '../model/cartStore';
const PRODUCT = { id: '1', title: 'Кофе', price: 250 };
export function ProductsScreen() { const addItem = useCart((s) => s.addItem); // точечная подписка return ( <View> <Text>{PRODUCT.title} — {PRODUCT.price} ₽</Text> <Button title="В корзину" onPress={() => addItem(PRODUCT)} /> </View> );}Экран 2 — просмотр корзины (подписан на items):
import { View, Text, Button, FlatList } from 'react-native';import { useCart } from '../model/cartStore';
export function CartScreen() { const items = useCart((s) => s.items); const clear = useCart((s) => s.clear); const total = items.reduce((sum, i) => sum + i.price, 0); return ( <View> <FlatList data={items} keyExtractor={(i) => i.id} renderItem={({ item }) => <Text>{item.title} — {item.price} ₽</Text>} /> <Text>Итого: {total} ₽</Text> <Button title="Очистить" onPress={clear} /> </View> );}Сравните с Context (3–5 предложений в отчёте). Опишите, что изменилось бы на
Context API: понадобился бы CartProvider с useReducer внутри; любое изменение
корзины перерисовывало бы всех потребителей контекста, включая
ProductsScreen, которому нужен только addItem; селекторы пришлось бы
эмулировать вручную. В Zustand ProductsScreen не перерисуется при изменении
items, потому что подписан только на addItem.
Задание 3. Простая DI через Context
Сделайте так, чтобы экраны получали apiClient из провайдера, а не импортировали
конкретную реализацию. Это развязывает presentation и data и упрощает тесты
(подмена на mock).
Абстракция и реализация:
export interface ApiClient { get<T>(path: string): Promise<T>; post<T>(path: string, body: unknown): Promise<T>;}
export function createHttpClient(baseUrl: string): ApiClient { const request = async <T>(path: string, init?: RequestInit): Promise<T> => { const res = await fetch(baseUrl + path, init); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise<T>; }; return { get: (path) => request(path), post: (path, body) => request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }), };}Composition root — единая точка сборки зависимостей:
import { createHttpClient, ApiClient } from '../../shared/api/apiClient';
export type Services = { api: ApiClient };
export function createServices(): Services { return { api: createHttpClient('https://api.example.com') };}Провайдер и хук:
import { createContext, useContext, useMemo, ReactNode } from 'react';import { Services, createServices } from './services';
const ServicesContext = createContext<Services | null>(null);
export function ServicesProvider({ children }: { children: ReactNode }) { const services = useMemo(() => createServices(), []); return ( <ServicesContext.Provider value={services}> {children} </ServicesContext.Provider> );}
export function useApi(): Services['api'] { const ctx = useContext(ServicesContext); if (!ctx) throw new Error('useApi должен вызываться внутри ServicesProvider'); return ctx.api;}Подключение и использование — оберните дерево провайдером, а в экране берите сервис через хук, без прямого импорта реализации:
export default function App() { return ( <ServicesProvider> <RootNavigator /> </ServicesProvider> );}
// в любом экране:const api = useApi(); // не импортируем createHttpClient// const list = await api.get('/products');В тесте достаточно обернуть экран в ServicesProvider с подменённым
createServices, возвращающим mock ApiClient — без обращения к сети.
Критерии оценки
- 30% — Задание 1. Проект реорганизован в feature-модули, дерево каталогов
соответствует требованиям, фичи закрыты публичным
index.ts,sharedтонкий. - 35% — Задание 2. Zustand-стор создан и работает на двух экранах; подписки выполнены через селекторы; приведено корректное сравнение с Context.
- 25% — Задание 3. DI через Context реализован: есть абстракция
ApiClient, composition root, провайдер и хукuseApi; экраны не импортируют реализацию. - 10% — Качество. Проект собирается без ошибок, код читаем, импорты
направлены внутрь (presentation → model → api), пройден
npx expo start.
Вопросы для самопроверки
- Почему
ProductsScreenна Zustand не перерисовывается при измененииitems, а на «наивном» Context — перерисовался бы? - Какое состояние стоит держать в Zustand, а какое — в локальном
useStateили в TanStack Query? Приведите по примеру. - Зачем экрану получать
apiClientчерезuseApi, а не импортироватьcreateHttpClientнапрямую? Как это помогает в тестах? - Что такое composition root и почему граф зависимостей собирают в одном месте?
- По каким признакам понятно, что код пора вынести в
shared, а не оставлять внутри фичи? Чем опасен «раздувшийся»shared?
Ресурсы
- Документация Zustand: https://zustand.docs.pmnd.rs/
- React Context: https://react.dev/reference/react/createContext
- Expo (Router и структура проекта): https://docs.expo.dev/
- Лекция 11 курса:
lecture_11.md(разделы 2, 4, 6).