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

Практика 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-клиент); бизнес-логика конкретной фичи туда не попадает.
  • Зависимости направлены внутрь: presentationmodelapi.

Пример публичного интерфейса модуля:

src/features/cart/index.ts
export { CartScreen } from './presentation/CartScreen';
export { ProductsScreen } from './presentation/ProductsScreen';
export { useCart } from './model/cartStore';

Задание 2. Глобальное состояние на Zustand

Создайте стор корзины и используйте его на двух экранах: на экране списка товаров (добавление) и на экране корзины (просмотр и очистка).

src/features/cart/model/cartStore.ts
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):

src/features/cart/presentation/ProductsScreen.tsx
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):

src/features/cart/presentation/CartScreen.tsx
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).

Абстракция и реализация:

src/shared/api/apiClient.ts
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 — единая точка сборки зависимостей:

src/app/di/services.ts
import { createHttpClient, ApiClient } from '../../shared/api/apiClient';
export type Services = { api: ApiClient };
export function createServices(): Services {
return { api: createHttpClient('https://api.example.com') };
}

Провайдер и хук:

src/app/di/ServicesProvider.tsx
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;
}

Подключение и использование — оберните дерево провайдером, а в экране берите сервис через хук, без прямого импорта реализации:

App.tsx
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.

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

  1. Почему ProductsScreen на Zustand не перерисовывается при изменении items, а на «наивном» Context — перерисовался бы?
  2. Какое состояние стоит держать в Zustand, а какое — в локальном useState или в TanStack Query? Приведите по примеру.
  3. Зачем экрану получать apiClient через useApi, а не импортировать createHttpClient напрямую? Как это помогает в тестах?
  4. Что такое composition root и почему граф зависимостей собирают в одном месте?
  5. По каким признакам понятно, что код пора вынести в shared, а не оставлять внутри фичи? Чем опасен «раздувшийся» shared?

Ресурсы