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

Лекция 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 вручную.

Три части работы с контекстом:

  1. createContext — создание контекста;
  2. Provider — поставщик значения для поддерева;
  3. useContext — чтение значения в любом потомке.

4.1. Пример AuthContext

Аутентификация — классический случай для Context: пользователь и токен нужны многим экранам (профиль, защищённые маршруты, сетевой слой).

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 [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 оборачивает ту часть дерева, которой нужен доступ к контексту. Обычно это корень приложения.

App.js
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 (createContextProvideruseContext) убирает prop drilling и доставляет глобальные данные напрямую; пример — AuthContext с пользователем и токеном.
  • Redux и Zustand — внешние сторы для большого/часто меняющегося состояния; начинать стоит с простого и усложнять по мере необходимости.
  • Серверное состояние лучше отдать React Query/SWR: кеш, статусы загрузки, фоновое обновление и повторные попытки уже реализованы за вас.

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

  1. Какие уровни состояния существуют в мобильных приложениях? Объясните разницу между локальным, глобальным и серверным состоянием и приведите примеры.
  2. Когда использовать useState, а когда useReducer? Что такое reducer и почему он должен быть чистой функцией?
  3. Что такое подъём состояния (lifting state up) и какую проблему создаёт prop drilling?
  4. Опишите работу Context API. Из каких трёх частей состоит работа с контекстом и как построить AuthContext?
  5. Сравните Context API, Redux и Zustand. В каких случаях выбирать каждый из подходов?
  6. Чем серверное состояние отличается от клиентского и какие задачи решают React Query/SWR?