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

Практика 6. Практическая работа 6. Управление состоянием (useReducer, Context API)

Цели работы

  • Научиться управлять локальным состоянием с помощью useReducer (action, dispatch, чистый reducer).
  • Освоить глобальное состояние через Context API: createContext, Provider, useContext.
  • Понять, какой инструмент выбирать под конкретный уровень состояния.
  • Закрепить материал лекции 6 на практике: корзина, тема приложения, авторизация.

Коротко о теории

Состояние делится на три уровня:

УровеньКому принадлежитИнструменты
Локальноеодин компонентuseState, useReducer
Глобальноевсё приложениеContext API, Redux
Серверноебэкенд (кеш)React Query, SWR
  • useReducer подходит, когда состояние — объект со многими полями и много типов действий. Reducer — это чистая функция (state, action) => newState: она не меняет состояние, а возвращает новое.
  • Context API убирает prop drilling и доставляет глобальные данные напрямую любому потомку, минуя промежуточные компоненты. Работа состоит из трёх частей: createContextProvideruseContext.

Задание

Подготовка. Создайте проект и запустите его:

Окно терминала
npx create-expo-app@latest state-lab
cd state-lab
npm start

Весь код раскладывайте по папкам: src/state/ — контексты, src/screens/ — экраны.

Задание 1. Корзина через useReducer

Реализуйте экран корзины, где состояние управляется через useReducer. Поддержите действия: add (добавить товар), remove (убрать товар), clear (очистить).

Создайте src/screens/CartScreen.js:

import { useReducer } from 'react';
import { View, Text, Button, FlatList, StyleSheet } from 'react-native';
const initialState = { items: [] };
// Чистая функция (state, action) => newState
function cartReducer(state, action) {
switch (action.type) {
case 'add': {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return { items: [...state.items, { ...action.payload, qty: 1 }] };
}
case 'remove':
return { items: state.items.filter(i => i.id !== action.payload.id) };
case 'clear':
return initialState;
default:
return state;
}
}
const CATALOG = [
{ id: 1, name: 'Кофе', price: 150 },
{ id: 2, name: 'Чай', price: 120 },
{ id: 3, name: 'Пирожок', price: 80 },
];
export default function CartScreen() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const total = state.items.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<View style={styles.container}>
<Text style={styles.title}>Каталог</Text>
{CATALOG.map(product => (
<Button
key={product.id}
title={`+ ${product.name} (${product.price} ₽)`}
onPress={() => dispatch({ type: 'add', payload: product })}
/>
))}
<Text style={styles.title}>Корзина</Text>
<FlatList
data={state.items}
keyExtractor={i => String(i.id)}
ListEmptyComponent={<Text>Корзина пуста</Text>}
renderItem={({ item }) => (
<View style={styles.row}>
<Text>{item.name} × {item.qty}</Text>
<Button
title="Убрать"
onPress={() => dispatch({ type: 'remove', payload: item })}
/>
</View>
)}
/>
<Text style={styles.total}>Итого: {total}</Text>
<Button title="Очистить" onPress={() => dispatch({ type: 'clear' })} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 8 },
title: { fontSize: 18, fontWeight: 'bold', marginTop: 12 },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
total: { fontSize: 16, fontWeight: 'bold', marginTop: 8 },
});

Проверьте: добавление повторяет товар увеличением qty, кнопка «Убрать» удаляет позицию, «Очистить» сбрасывает корзину, итог пересчитывается автоматически.

Задание 2. Глобальная тема через Context API

Создайте ThemeContext, оберните приложение в Provider, а в экранах читайте тему через useContext и добавьте переключатель light/dark.

Создайте src/state/ThemeContext.js:

import { createContext, useContext, useMemo, useState } from 'react';
const ThemeContext = createContext(null);
const themes = {
light: { bg: '#ffffff', text: '#111111' },
dark: { bg: '#111111', text: '#ffffff' },
};
export function ThemeProvider({ children }) {
const [mode, setMode] = useState('light');
const toggleTheme = () => setMode(prev => (prev === 'light' ? 'dark' : 'light'));
// useMemo, чтобы value не пересоздавался на каждый рендер
const value = useMemo(
() => ({ mode, colors: themes[mode], toggleTheme }),
[mode]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
// Хук-обёртка: безопаснее, чем useContext напрямую
export function useTheme() {
const ctx = useContext(ThemeContext);
if (ctx === null) {
throw new Error('useTheme должен использоваться внутри ThemeProvider');
}
return ctx;
}

Подключите провайдер в корне (App.js):

import { ThemeProvider } from './src/state/ThemeContext';
import HomeScreen from './src/screens/HomeScreen';
export default function App() {
return (
<ThemeProvider>
<HomeScreen />
</ThemeProvider>
);
}

Используйте тему в экране (src/screens/HomeScreen.js):

import { View, Text, Button } from 'react-native';
import { useTheme } from '../state/ThemeContext';
export default function HomeScreen() {
const { mode, colors, toggleTheme } = useTheme();
return (
<View style={{ flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: 16 }}>
<Text style={{ color: colors.text, fontSize: 20 }}>Текущая тема: {mode}</Text>
<Button title="Переключить тему" onPress={toggleTheme} />
</View>
);
}

Проверьте: нажатие на кнопку меняет фон и цвет текста сразу на всех экранах, читающих контекст.

Вместо темы можно сделать переключатель языка (ru/en) — структура контекста та же: храните lang, отдавайте словарь строк и функцию setLang.

Задание 3 (дополнительное). Мини-AuthContext

Добавьте глобальное состояние авторизации: вход и выход меняют его, а экраны реагируют.

Создайте src/state/AuthContext.js:

import { createContext, useContext, useMemo, useState } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const signIn = name => setUser({ id: 1, name });
const signOut = () => setUser(null);
const value = useMemo(
() => ({ user, isAuthenticated: !!user, signIn, signOut }),
[user]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (ctx === null) {
throw new Error('useAuth должен использоваться внутри AuthProvider');
}
return ctx;
}

Оберните приложение ещё одним провайдером (контексты разделяем — так лишние перерисовки не задевают чужих потребителей):

import { AuthProvider } from './src/state/AuthContext';
import { ThemeProvider } from './src/state/ThemeContext';
export default function App() {
return (
<AuthProvider>
<ThemeProvider>
<HomeScreen />
</ThemeProvider>
</AuthProvider>
);
}

Экран реагирует на состояние авторизации:

import { View, Text, Button } from 'react-native';
import { useAuth } from '../state/AuthContext';
export default function AuthScreen() {
const { user, isAuthenticated, signIn, signOut } = useAuth();
if (!isAuthenticated) {
return (
<View style={{ padding: 16 }}>
<Text>Вы не авторизованы</Text>
<Button title="Войти" onPress={() => signIn('Иван')} />
</View>
);
}
return (
<View style={{ padding: 16 }}>
<Text>Привет, {user.name}!</Text>
<Button title="Выйти" onPress={signOut} />
</View>
);
}

Проверьте: вход показывает приветствие, выход возвращает экран входа, и любой другой экран, читающий useAuth, реагирует одинаково.


Критерии оценки

  • 40% — Задание 1: useReducer с действиями add/remove/clear, reducer — чистая функция, корзина отрисовывается и пересчитывается.
  • 35% — Задание 2: создан ThemeContext, провайдер в корне, экран читает тему через useContext, работает переключатель.
  • 15% — Задание 3: AuthContext, вход/выход меняют глобальное состояние, экраны реагируют.
  • 10% — Качество кода: разделение по файлам, хуки-обёртки (useTheme/useAuth), useMemo для value, осмысленные имена.

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

  1. Почему reducer должен быть чистой функцией и что произойдёт, если менять state напрямую?
  2. Чем dispatch({ type: 'add' }) принципиально отличается от вызова setState?
  3. Из каких трёх частей состоит работа с Context API?
  4. Зачем оборачивать value провайдера в useMemo?
  5. Почему тему и авторизацию лучше держать в разных контекстах, а не в одном?
  6. В каком случае вместо Context стоит перейти на Redux или Zustand?

Ресурсы