Лекция 6. Управление состоянием в мобильных приложениях
Введение
Состояние (state) — это данные, которые определяют, что приложение отображает и как оно реагирует на действия пользователя в данный момент времени. В React Native пользовательский интерфейс — это функция от состояния: меняется состояние — перерисовывается интерфейс.
Грамотное управление состоянием — один из ключевых навыков мобильного разработчика. От него зависят читаемость кода, производительность приложения, лёгкость поддержки и количество ошибок. В этой лекции мы разберём:
- какие уровни состояния существуют и чем они различаются;
- когда применять
useState, а когдаuseReducer; - что такое подъём состояния (lifting state up) и проблему prop drilling;
- как работает Context API и как построить
AuthContext; - обзор внешних библиотек (Redux, Zustand) и серверного состояния (React Query/SWR).
1. Уровни состояния
Прежде чем выбирать инструмент, важно понять, к какому уровню относится конкретный фрагмент данных. Выделяют три уровня.
1.1. Локальное состояние (component / screen state)
Принадлежит одному компоненту или экрану и не нужно остальной части приложения.
Примеры:
- текст, введённый в поле формы;
- открыт или закрыт выпадающий список / модальное окно;
- активная вкладка внутри экрана;
- индикатор загрузки конкретного блока.
Инструменты: хуки useState, useReducer, вспомогательные useMemo,
useCallback.
1.2. Глобальное состояние (application state)
Данные, нужные многим экранам одновременно, «общие» для всего приложения.
Примеры:
- текущий пользователь и токен авторизации;
- выбранная тема (светлая/тёмная) и язык интерфейса;
- содержимое корзины в интернет-магазине;
- настройки приложения.
Инструменты: Context API, Redux, Zustand, MobX.
1.3. Серверное состояние (server state)
Это копия данных, которые «живут» на бэкенде, а в приложении хранятся как кеш. Главная особенность — данные могут устаревать, их нужно синхронизировать.
Примеры:
- список товаров, лента новостей, профиль из API;
- результаты поиска;
- любые данные, полученные по сети.
Инструменты: React Query (TanStack Query), SWR, RTK Query.
1.4. Сравнительная таблица
| Уровень | Кому принадлежит | Источник истины | Чем управлять |
|---|---|---|---|
| Локальное | один компонент | сам компонент | useState, useReducer |
| Глобальное | всё приложение | стор приложения | Context, Redux, Zustand |
| Серверное | бэкенд (кеш у нас) | сервер | React Query, SWR |
Частая ошибка новичков — складывать всё в один глобальный стор. На практике большая часть состояния локальна, а серверные данные лучше доверить специализированному кешу, а не хранить вручную в Context/Redux.
2. Локальное состояние: useState vs useReducer
2.1. useState — для простого состояния
useState подходит, когда состояние — это одно-два независимых значения с
простыми переходами.
import { useState } from 'react';import { Text, Button, View } from 'react-native';
export function Counter() { const [count, setCount] = useState(0); return ( <View> <Text>Счёт: {count}</Text> <Button title="+" onPress={() => setCount(count + 1)} /> <Button title="-" onPress={() => setCount(count - 1)} /> </View> );}Когда обновление зависит от предыдущего значения, используйте функциональную форму, чтобы избежать ошибок при батчинге обновлений:
setCount(prev => prev + 1);2.2. useReducer — для сложного состояния
useReducer стоит выбирать, когда:
- состояние — это объект с несколькими взаимосвязанными полями;
- переходы между состояниями сложные (много типов действий);
- следующее состояние зависит от предыдущего по нетривиальным правилам;
- логику обновления хочется вынести и протестировать отдельно от компонента.
Reducer — это чистая функция (state, action) => newState. Она не меняет
состояние напрямую, а возвращает новое.
Пример: состояние загрузки данных с полями loading, data, error.
import { useReducer, useEffect } from 'react';import { Text } from 'react-native';
const initialState = { loading: false, data: null, error: null };
function fetchReducer(state, action) { switch (action.type) { case 'start': return { ...state, loading: true, error: null }; case 'success': return { loading: false, data: action.payload, error: null }; case 'failure': return { loading: false, data: null, error: action.error }; case 'reset': return initialState; default: return state; }}
export function UserProfile({ userId }) { const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => { let active = true; dispatch({ type: 'start' }); fetch(`https://api.example.com/users/${userId}`) .then(res => res.json()) .then(data => active && dispatch({ type: 'success', payload: data })) .catch(err => active && dispatch({ type: 'failure', error: err.message })); return () => { active = false; }; }, [userId]);
if (state.loading) return <Text>Загрузка…</Text>; if (state.error) return <Text>Ошибка: {state.error}</Text>; return <Text>{state.data?.name}</Text>;}Преимущества подхода: все возможные изменения состояния собраны в одном месте
(reducer), компонент лишь отправляет «события» через dispatch. Это делает
логику предсказуемой и тестируемой.
2.3. Правило выбора
| Ситуация | Выбор |
|---|---|
| 1–2 независимых значения, простые переходы | useState |
| Объект со многими полями | useReducer |
| Много типов действий над состоянием | useReducer |
| Логику нужно тестировать отдельно | useReducer |
useReducer концептуально близок к Redux: те же идеи action и reducer, но без
внешней библиотеки и только в пределах компонента.
3. Подъём состояния и prop drilling
3.1. Подъём состояния (lifting state up)
Если двум соседним компонентам нужно общее состояние, его «поднимают» в их ближайшего общего родителя, а вниз передают через props значение и функции-обработчики.
function Parent() { const [query, setQuery] = useState(''); return ( <> <SearchInput value={query} onChange={setQuery} /> <SearchResults query={query} /> </> );}Здесь Parent владеет состоянием, а SearchInput и SearchResults его
разделяют. Это базовый и правильный приём для двух-трёх близко расположенных
компонентов.
3.2. Проблема prop drilling
Когда состояние нужно компоненту, находящемуся глубоко в дереве, его приходится прокидывать через множество промежуточных компонентов, которым эти данные не нужны.
App (token) └─ Layout (token) // просто прокидывает дальше └─ Sidebar (token) // просто прокидывает дальше └─ UserBadge (token) // здесь реально используетсяПроблемы такого «бурения пропсов»:
- промежуточные компоненты захламляются ненужными им props;
- любое изменение сигнатуры данных правит всю цепочку;
- усложняется переиспользование компонентов;
- код тяжело читать и поддерживать.
Решение — Context API или внешний стор, которые позволяют компоненту получить данные напрямую, минуя промежуточные звенья.
4. Context API
Context API — встроенный механизм React для передачи данных по дереву компонентов без прокидывания props вручную.
Три части работы с контекстом:
createContext— создание контекста;Provider— поставщик значения для поддерева;useContext— чтение значения в любом потомке.
4.1. Пример AuthContext
Аутентификация — классический случай для Context: пользователь и токен нужны многим экранам (профиль, защищённые маршруты, сетевой слой).
import { createContext, useContext, useMemo, useState } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) { const [user, setUser] = useState(null); const [token, setToken] = useState(null);
function signIn(userData, authToken) { setUser(userData); setToken(authToken); }
function signOut() { setUser(null); setToken(null); }
// useMemo, чтобы объект value не пересоздавался на каждый рендер const value = useMemo( () => ({ user, token, isAuthenticated: !!token, signIn, signOut }), [user, token] );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;}
// Хук-обёртка: удобнее и безопаснее, чем useContext напрямуюexport function useAuth() { const context = useContext(AuthContext); if (context === null) { throw new Error('useAuth должен использоваться внутри AuthProvider'); } return context;}4.2. Подключение провайдера
Provider оборачивает ту часть дерева, которой нужен доступ к контексту. Обычно это корень приложения.
import { AuthProvider } from './src/state/AuthContext';
export default function App() { return ( <AuthProvider> {/* навигация и все экраны */} </AuthProvider> );}4.3. Использование в компонентах
import { useAuth } from '../state/AuthContext';import { Button, Text } from 'react-native';
export function LoginScreen() { const { signIn } = useAuth();
async function onLogin() { // обычно токен и данные приходят из API const token = 'mock.jwt.token'; const user = { id: 1, name: 'Иван' }; signIn(user, token); }
return <Button title="Войти" onPress={onLogin} />;}
export function ProfileScreen() { const { user, signOut } = useAuth(); return ( <> <Text>Привет, {user?.name}!</Text> <Button title="Выйти" onPress={signOut} /> </> );}4.4. Особенности и ограничения Context
- Любое изменение значения контекста перерисовывает всех потребителей. Поэтому
не стоит складывать в один контекст часто меняющиеся независимые данные —
лучше разделять контексты (например,
AuthContextиThemeContext). useMemoдля объектаvalueпомогает избежать лишних перерисовок.- Context решает задачу «доставки» данных, но не оптимизирует обновления так, как специализированные сторы. Для часто меняющегося большого состояния это может стать узким местом — тогда подключают Redux или Zustand.
5. Внешние решения: Redux и Zustand (обзор)
Когда глобального состояния становится много, оно часто меняется, требуется отладка и предсказуемость — на помощь приходят внешние библиотеки. Здесь — краткий обзор; детальное сравнение будет в лекции 11.
5.1. Redux (Redux Toolkit)
- Однонаправленный поток данных:
action → reducer → новый store → UI. - Единый централизованный store, предсказуемые изменения, мощные инструменты отладки (Redux DevTools, time-travel).
- Подходит для крупных приложений со сложной бизнес-логикой.
- Минусы: больше шаблонного кода, выше порог входа (даже с Redux Toolkit, который этот объём заметно сокращает).
5.2. Zustand
- Минималистичный стор на основе хуков, очень мало шаблонного кода.
- Не требует оборачивать приложение в Provider.
import { create } from 'zustand';
const useCounterStore = create(set => ({ count: 0, increment: () => set(state => ({ count: state.count + 1 })), reset: () => set({ count: 0 }),}));
// В компоненте:// const count = useCounterStore(state => state.count);// const increment = useCounterStore(state => state.increment);- Подходит, когда нужно простое глобальное состояние без тяжёлого Redux.
5.3. Как выбирать
| Инструмент | Когда выбирать |
|---|---|
| Context API | немного редко меняющихся данных (auth, тема, язык) |
| Zustand | простое глобальное состояние, минимум кода |
| Redux | большое приложение, сложная логика, нужна строгая отладка |
Главный принцип: не усложнять заранее. Начинайте с useState/Context и
переходите на внешний стор, когда появляется реальная потребность.
6. Серверное состояние: кеширование запросов
Серверные данные принципиально отличаются от обычного состояния: они
асинхронны, могут устаревать, их нужно перезапрашивать и синхронизировать.
Хранить их вручную в useState или Redux — значит каждый раз заново
реализовывать загрузку, кеш, повторные попытки, инвалидацию.
Специализированные библиотеки — React Query (TanStack Query) и SWR — берут это на себя.
Что они дают «из коробки»:
- кеширование ответов по ключу запроса;
- автоматический статус
loading/error/success; - дедупликацию одинаковых запросов;
- фоновое обновление устаревших данных (stale-while-revalidate);
- повторные попытки при ошибках и инвалидацию кеша.
6.1. Пример с React Query
import { QueryClient, QueryClientProvider, useQuery,} from '@tanstack/react-query';import { Text } from 'react-native';
const queryClient = new QueryClient();
function TodoList() { const { data, isLoading, error } = useQuery({ queryKey: ['todos'], queryFn: async () => (await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5')).json(), });
if (isLoading) return <Text>Загрузка…</Text>; if (error) return <Text>Ошибка загрузки</Text>; return data.map(item => <Text key={item.id}>{item.title}</Text>);}
export default function App() { return ( <QueryClientProvider client={queryClient}> <TodoList /> </QueryClientProvider> );}Идея: queryKey (['todos']) — это идентификатор данных в кеше. Повторный
запрос с тем же ключом сначала вернёт закешированный результат, а затем при
необходимости обновит его в фоне. Это разгружает компоненты и сеть.
Вывод: разделяйте серверное и клиентское состояние. Серверное доверьте React Query/SWR, а Context/Redux/Zustand оставьте для собственно клиентских данных (auth, UI, настройки).
Краткие итоги
- Состояние делится на три уровня: локальное (компонент), глобальное (приложение) и серверное (кеш данных бэкенда). Под каждый уровень — свои инструменты.
useState— для простых значений;useReducer— для сложного объекта со многими полями и нетривиальными переходами; reducer — чистая функция(state, action) => newState.- Подъём состояния решает совместное использование данных соседними компонентами; при глубоком дереве он вырождается в prop drilling — прокидывание props через ненужные промежуточные слои.
- Context API (
createContext→Provider→useContext) убирает prop drilling и доставляет глобальные данные напрямую; пример —AuthContextс пользователем и токеном. - Redux и Zustand — внешние сторы для большого/часто меняющегося состояния; начинать стоит с простого и усложнять по мере необходимости.
- Серверное состояние лучше отдать React Query/SWR: кеш, статусы загрузки, фоновое обновление и повторные попытки уже реализованы за вас.
Вопросы для самопроверки
- Какие уровни состояния существуют в мобильных приложениях? Объясните разницу между локальным, глобальным и серверным состоянием и приведите примеры.
- Когда использовать
useState, а когдаuseReducer? Что такое reducer и почему он должен быть чистой функцией? - Что такое подъём состояния (lifting state up) и какую проблему создаёт prop drilling?
- Опишите работу Context API. Из каких трёх частей состоит работа с контекстом
и как построить
AuthContext? - Сравните Context API, Redux и Zustand. В каких случаях выбирать каждый из подходов?
- Чем серверное состояние отличается от клиентского и какие задачи решают React Query/SWR?