Практика 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 и доставляет глобальные данные напрямую любому потомку,
минуя промежуточные компоненты. Работа состоит из трёх частей:
createContext→Provider→useContext.
Задание
Подготовка. Создайте проект и запустите его:
Окно терминала npx create-expo-app@latest state-labcd state-labnpm 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) => newStatefunction 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, осмысленные имена.
Вопросы для самопроверки
- Почему reducer должен быть чистой функцией и что произойдёт, если менять
stateнапрямую? - Чем
dispatch({ type: 'add' })принципиально отличается от вызоваsetState? - Из каких трёх частей состоит работа с Context API?
- Зачем оборачивать
valueпровайдера вuseMemo? - Почему тему и авторизацию лучше держать в разных контекстах, а не в одном?
- В каком случае вместо Context стоит перейти на Redux или Zustand?
Ресурсы
- React: useReducer
- React: useContext и createContext
- React Native: компоненты Button и FlatList
- Материал лекции 6 «Управление состоянием в мобильных приложениях».