Введение: зачем вообще думать об управлении состоянием в 2026 году
Ну, давайте честно. Если вы хоть раз пытались ответить на вопрос «какую библиотеку для стейт-менеджмента выбрать?» — вы знаете, что это одна из тех тем, которые вызывают жаркие дискуссии в сообществе. Redux, MobX, Context API, Recoil, Jotai, Zustand, Valtio — список длиннее, чем корзина покупок на чёрную пятницу. Но вот что радует: в 2026 году ландшафт наконец-то стал упорядоченным, и появился чёткий, проверенный практикой подход.
Суть проста: разделяй клиентское и серверное состояние. Клиентское — это тема оформления, состояние модальных окон, локальные формы. Серверное — данные с API, кэш, синхронизация. Попытка запихнуть всё в одну корзину (вспомните Redux-эпоху, когда API-ответы хранились рядом с флагом isModalOpen) — это путь к запутанному коду и бесконечным ререндерам. Проходили, знаем.
В этом руководстве разберём современный стек для React Native:
- Zustand v5 — для клиентского состояния. Минималистичный, быстрый, без бойлерплейта.
- TanStack Query v5 — для серверного состояния. Кэширование, ретраи, рефетчинг, оффлайн-поддержка.
- MMKV — для персистентного хранения. В 30 раз быстрее AsyncStorage (и это не маркетинг).
Каждый раздел содержит рабочие примеры кода для Expo SDK 55 и React Native 0.83. Итак, поехали.
Клиентское vs серверное состояние: почему это важно
Прежде чем углубляться в конкретные библиотеки, давайте чётко разберёмся с этим разделением. Оно определяет всю архитектуру вашего приложения, и если вы сделаете это правильно на старте — сэкономите себе массу нервов.
Клиентское состояние
Это данные, которые живут только на устройстве пользователя и не синхронизируются с сервером:
- Тема оформления (светлая/тёмная)
- Состояние открытия/закрытия модальных окон и bottom sheet
- Выбранная вкладка или фильтр в UI
- Данные формы до отправки
- Настройки уведомлений (локальные)
- Флаг прохождения онбординга
Для этого типа состояния идеально подходит Zustand — он легковесный, не требует провайдеров и работает за пределами React-дерева.
Серверное состояние
А вот это совсем другая история. Серверное состояние — это данные, полученные с API, которые нужно кэшировать, обновлять, синхронизировать и инвалидировать:
- Список товаров из API
- Профиль пользователя
- Лента новостей
- Результаты поиска
- Любые данные, которые могут измениться на сервере
Хранить всё это в Redux или Zustand — плохая идея. Вы по сути заново изобретаете кэширование, отслеживание состояния загрузки, обработку ошибок, рефетчинг при возвращении на экран. TanStack Query решает все эти задачи из коробки, и делает это значительно лучше, чем самописные решения.
Zustand v5: минималистичное управление клиентским состоянием
Zustand (в переводе с немецкого — «состояние») — библиотека от команды Poimandres, тех самых ребят, что создали React Three Fiber и Jotai. Текущая стабильная версия — 5.0.11, и она требует React 18+. Учитывая, что Expo SDK 55 использует React 19.2, совместимость полная.
Установка
# Для Expo-проектов
npx expo install zustand
# Для bare React Native
npm install zustand
# или
yarn add zustand
Всё. Никаких дополнительных настроек, Babel-плагинов или провайдеров. Серьёзно, это вся установка.
Создание простого стора
Вот что мне нравится в Zustand — его API невероятно лаконичный. Базовый пример стора для управления темой:
// stores/useThemeStore.ts
import { create } from 'zustand';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeState {
mode: ThemeMode;
accentColor: string;
setMode: (mode: ThemeMode) => void;
setAccentColor: (color: string) => void;
toggleMode: () => void;
}
export const useThemeStore = create<ThemeState>((set) => ({
mode: 'system',
accentColor: '#6366f1',
setMode: (mode) => set({ mode }),
setAccentColor: (accentColor) => set({ accentColor }),
toggleMode: () =>
set((state) => ({
mode: state.mode === 'dark' ? 'light' : 'dark',
})),
}));
И использование в компоненте — тоже без сюрпризов:
// components/ThemeToggle.tsx
import React from 'react';
import { TouchableOpacity, Text, View, StyleSheet } from 'react-native';
import { useThemeStore } from '../stores/useThemeStore';
export function ThemeToggle() {
const { mode, toggleMode } = useThemeStore();
return (
<TouchableOpacity onPress={toggleMode} style={styles.button}>
<Text style={styles.text}>
{mode === 'dark' ? 'Переключить на светлую' : 'Переключить на тёмную'}
</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
button: {
padding: 12,
backgroundColor: '#6366f1',
borderRadius: 8,
},
text: {
color: '#fff',
textAlign: 'center',
fontWeight: '600',
},
});
Обратите внимание: никаких Provider-обёрток, никакого useContext. Zustand работает на основе подписок — компонент подписывается только на те части стора, которые он использует, и ререндерится только при их изменении. Красота.
Ключевые изменения в Zustand v5
Если вы переходите с четвёртой версии, вот что стоит знать:
1. Переход на useSyncExternalStore — Zustand v5 теперь использует нативный хук React вместо полифила. Это улучшает производительность и совместимость с concurrent-фичами React 19.
2. Изменения в функции сравнения — функция create больше не поддерживает кастомную equality function. Если вам нужно неглубокое сравнение, используйте useShallow:
import { useShallow } from 'zustand/react/shallow';
// Вместо: useStore(selector, shallow)
// Теперь:
const { mode, accentColor } = useThemeStore(
useShallow((state) => ({
mode: state.mode,
accentColor: state.accentColor,
}))
);
3. Требуется React 18+ — совместимость с React 17 и ниже убрана. Для нас с Expo SDK 55 (React 19.2) это вообще не проблема.
Паттерн: разделение сторов по функциональности
Вот одна ошибка, которую я часто вижу: попытка впихнуть всё приложение в один гигантский стор. Не надо так. Создавайте отдельные сторы для разных доменов:
// stores/useAuthStore.ts — авторизация
export const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
user: null,
token: null,
login: (user, token) => set({ isAuthenticated: true, user, token }),
logout: () => set({ isAuthenticated: false, user: null, token: null }),
}));
// stores/useCartStore.ts — корзина покупок
export const useCartStore = create<CartState>((set, get) => ({
items: [],
addItem: (product) =>
set((state) => {
const existing = state.items.find((i) => i.id === product.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...state.items, { ...product, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
getTotalPrice: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
clearCart: () => set({ items: [] }),
}));
// stores/useOnboardingStore.ts — онбординг
export const useOnboardingStore = create<OnboardingState>((set) => ({
hasCompletedOnboarding: false,
currentStep: 0,
nextStep: () => set((state) => ({ currentStep: state.currentStep + 1 })),
completeOnboarding: () => set({ hasCompletedOnboarding: true }),
}));
Каждый стор независим, тестируется отдельно и подключается только к тем компонентам, которым он реально нужен. Чисто и понятно.
Персистенция с MMKV: состояние, которое переживает перезапуск
AsyncStorage — стандартное решение для хранения данных в React Native, но у него есть серьёзная проблема: производительность. Он асинхронный, основан на SQLite (Android) и NSUserDefaults/файловой системе (iOS), и заметно тормозит при частых операциях записи.
MMKV от WeChat/Tencent — это высокопроизводительное хранилище ключ-значение, примерно в 30 раз быстрее AsyncStorage. Оно использует memory-mapped файлы, что обеспечивает синхронный доступ к данным. Честно говоря, после перехода на MMKV возвращаться к AsyncStorage уже не хочется.
Установка MMKV
# Для Expo (с development build)
npx expo install react-native-mmkv
# Для bare React Native
npm install react-native-mmkv
cd ios && pod install
Важно: MMKV содержит нативный код, поэтому в Expo он работает только с development build или EAS Build, но не в Expo Go. Имейте это в виду при планировании.
Интеграция Zustand + MMKV
Zustand имеет встроенный middleware persist, который поддерживает кастомные хранилища. Подключение MMKV выглядит так:
// lib/mmkvStorage.ts
import { MMKV } from 'react-native-mmkv';
import { StateStorage } from 'zustand/middleware';
// Создаём экземпляр MMKV
const storage = new MMKV({
id: 'app-storage',
// Опционально: шифрование
// encryptionKey: 'your-encryption-key',
});
// Адаптер для Zustand
export const mmkvStorage: StateStorage = {
setItem: (name, value) => {
storage.set(name, value);
},
getItem: (name) => {
return storage.getString(name) ?? null;
},
removeItem: (name) => {
storage.delete(name);
},
};
Теперь подключаем persist middleware к стору:
// stores/useThemeStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { mmkvStorage } from '../lib/mmkvStorage';
interface ThemeState {
mode: 'light' | 'dark' | 'system';
accentColor: string;
setMode: (mode: 'light' | 'dark' | 'system') => void;
setAccentColor: (color: string) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
mode: 'system',
accentColor: '#6366f1',
setMode: (mode) => set({ mode }),
setAccentColor: (accentColor) => set({ accentColor }),
}),
{
name: 'theme-storage',
storage: createJSONStorage(() => mmkvStorage),
}
)
);
Обработка гидратации
Тут есть один подводный камень, о котором стоит знать. При использовании persist возникает так называемый flash of initial state. Когда приложение запускается, стор сначала инициализируется с дефолтными значениями, а потом гидратируется из хранилища. На практике это может привести к мерцанию UI — экран на долю секунды мигнёт светлой темой, а потом переключится на тёмную.
Решение — отслеживать статус гидратации:
// components/HydrationGate.tsx
import React, { useEffect, useState, ReactNode } from 'react';
import { useThemeStore } from '../stores/useThemeStore';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
export function HydrationGate({ children, fallback = null }: Props) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
// Zustand persist expose onFinishHydration
const unsubFinishHydration =
useThemeStore.persist.onFinishHydration(() => {
setHydrated(true);
});
// Если стор уже гидратирован
if (useThemeStore.persist.hasHydrated()) {
setHydrated(true);
}
return () => {
unsubFinishHydration();
};
}, []);
if (!hydrated) return <>{fallback}</>;
return <>{children}</>;
}
Оберните корневой компонент этим gate — и мерцание исчезнет. Мелочь, но пользователи такое замечают.
TanStack Query v5: серверное состояние без головной боли
TanStack Query (ранее React Query) — библиотека, которая радикально упрощает работу с серверными данными. Вместо того чтобы вручную писать useEffect + useState + try/catch для каждого API-запроса (мы все через это проходили), вы получаете декларативный интерфейс с автоматическим кэшированием, фоновым обновлением, ретраями и кучей других полезностей.
Установка
# Основной пакет
npx expo install @tanstack/react-query
# Для DevTools (опционально, но очень рекомендуется)
npx expo install @tanstack/react-query-devtools
# Для оффлайн-персистенции
npx expo install @tanstack/query-async-storage-persister @tanstack/react-query-persist-client
Начальная настройка
TanStack Query требует обёртку QueryClientProvider в корне приложения. Типичная конфигурация для React Native выглядит так:
// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Данные считаются свежими 5 минут
staleTime: 5 * 60 * 1000,
// Кэш хранится 30 минут после потери подписчиков
gcTime: 30 * 60 * 1000,
// Повторить запрос 3 раза при ошибке
retry: 3,
// Рефетчинг при переключении на приложение
refetchOnWindowFocus: false,
// Рефетчинг при восстановлении сети
refetchOnReconnect: true,
},
mutations: {
retry: 1,
},
},
});
Обратите внимание на refetchOnWindowFocus: false. В React Native нет «фокуса окна» в привычном смысле. Для рефетчинга при возвращении на экран мы используем интеграцию с React Navigation — об этом чуть ниже.
Подключение в корневом layout
// app/_layout.tsx
import React from 'react';
import { Stack } from 'expo-router';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../lib/queryClient';
import { HydrationGate } from '../components/HydrationGate';
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<HydrationGate>
<Stack screenOptions={{ headerShown: false }} />
</HydrationGate>
</QueryClientProvider>
);
}
Базовые запросы с useQuery
Вот пример получения списка товаров. Сначала определяем API-функции:
// api/products.ts
export interface Product {
id: string;
name: string;
price: number;
imageUrl: string;
category: string;
}
const API_BASE = 'https://api.example.com/v1';
export async function fetchProducts(
category?: string
): Promise<Product[]> {
const url = category
? `${API_BASE}/products?category=${category}`
: `${API_BASE}/products`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Ошибка загрузки товаров: ${response.status}`);
}
return response.json();
}
export async function fetchProduct(id: string): Promise<Product> {
const response = await fetch(`${API_BASE}/products/${id}`);
if (!response.ok) {
throw new Error(`Товар не найден: ${response.status}`);
}
return response.json();
}
Затем создаём хуки. И вот тут — важный паттерн: фабрика ключей запросов.
// hooks/useProducts.ts
import { useQuery } from '@tanstack/react-query';
import { fetchProducts, fetchProduct } from '../api/products';
// Фабрика ключей запросов — важный паттерн!
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (category?: string) =>
[...productKeys.lists(), { category }] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
export function useProducts(category?: string) {
return useQuery({
queryKey: productKeys.list(category),
queryFn: () => fetchProducts(category),
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: productKeys.detail(id),
queryFn: () => fetchProduct(id),
enabled: !!id,
});
}
Использование в компоненте — лаконично и понятно:
// screens/ProductListScreen.tsx
import React from 'react';
import { FlatList, View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { useProducts } from '../hooks/useProducts';
export function ProductListScreen() {
const { data: products, isLoading, error, refetch } = useProducts();
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#6366f1" />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.error}>Ошибка: {error.message}</Text>
</View>
);
}
return (
<FlatList
data={products}
keyExtractor={(item) => item.id}
onRefresh={refetch}
refreshing={isLoading}
renderItem={({ item }) => (
<View style={styles.card}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.price}>{item.price} ₽</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
error: { color: 'red', fontSize: 16 },
card: { padding: 16, borderBottomWidth: 1, borderColor: '#e5e7eb' },
name: { fontSize: 18, fontWeight: '600' },
price: { fontSize: 16, color: '#6366f1', marginTop: 4 },
});
Мутации: отправка данных на сервер
Мутации — это запросы, которые изменяют данные (POST, PUT, DELETE). TanStack Query предоставляет хук useMutation с поддержкой оптимистичных обновлений. Вот пример, который я считаю обязательным для любого мобильного приложения:
// hooks/useAddToFavorites.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { productKeys } from './useProducts';
async function addToFavorites(productId: string): Promise<void> {
const response = await fetch(
`https://api.example.com/v1/favorites/${productId}`,
{ method: 'POST' }
);
if (!response.ok) throw new Error('Не удалось добавить в избранное');
}
export function useAddToFavorites() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addToFavorites,
// Оптимистичное обновление
onMutate: async (productId) => {
// Отменяем текущие запросы, чтобы не перезаписать
await queryClient.cancelQueries({
queryKey: productKeys.detail(productId),
});
// Сохраняем предыдущее значение
const previousProduct = queryClient.getQueryData(
productKeys.detail(productId)
);
// Оптимистично обновляем кэш
queryClient.setQueryData(
productKeys.detail(productId),
(old: any) => old ? { ...old, isFavorite: true } : old
);
return { previousProduct };
},
// Откат при ошибке
onError: (_err, productId, context) => {
if (context?.previousProduct) {
queryClient.setQueryData(
productKeys.detail(productId),
context.previousProduct
);
}
},
// Инвалидация кэша при успехе
onSettled: () => {
queryClient.invalidateQueries({ queryKey: productKeys.all });
},
});
}
Оптимистичные обновления — мощный паттерн для мобильных приложений. Пользователь видит мгновенный отклик, а если запрос не прошёл — данные откатываются автоматически. Именно так и должен работать хороший UX на мобильных.
Интеграция с React Navigation и Expo Router
В веб-приложениях TanStack Query автоматически рефетчит данные при фокусе окна. В React Native этого нет, но есть аналог — useFocusEffect из React Navigation (Expo Router построен на нём).
Автоматический рефетчинг при фокусе экрана
// hooks/useRefreshOnFocus.ts
import { useCallback } from 'react';
import { useFocusEffect } from 'expo-router';
export function useRefreshOnFocus(refetch: () => void) {
useFocusEffect(
useCallback(() => {
refetch();
}, [refetch])
);
}
Использование — проще некуда:
function ProductListScreen() {
const { data, refetch } = useProducts();
useRefreshOnFocus(refetch);
// ...
}
Отслеживание состояния сети
Для корректной работы TanStack Query в оффлайн-режиме подключите мониторинг сети через @react-native-community/netinfo:
// lib/networkMode.ts
import { onlineManager } from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
// Подписываемся на изменения состояния сети
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected);
});
});
После этого TanStack Query автоматически приостанавливает запросы в оффлайне и возобновляет их при восстановлении сети. Мутации, созданные без подключения, будут поставлены в очередь и выполнены при появлении соединения. Магия? Нет, просто хорошо спроектированная библиотека.
Оффлайн-первый подход: персистенция кэша TanStack Query
Для полноценного оффлайн-опыта нужно не только ставить запросы на паузу, но и сохранять весь кэш TanStack Query между запусками приложения. Тогда пользователь увидит последние загруженные данные сразу при открытии — даже без интернета. Без пустых экранов и бесконечных спиннеров.
Настройка персистенции с MMKV
// lib/queryPersister.ts
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { MMKV } from 'react-native-mmkv';
const queryStorage = new MMKV({ id: 'query-cache' });
export const queryPersister = createSyncStoragePersister({
storage: {
getItem: (key) => queryStorage.getString(key) ?? null,
setItem: (key, value) => queryStorage.set(key, value),
removeItem: (key) => queryStorage.delete(key),
},
// Сериализация/десериализация
serialize: JSON.stringify,
deserialize: JSON.parse,
});
Обновлённый корневой layout
// app/_layout.tsx
import React from 'react';
import { Stack } from 'expo-router';
import { QueryClientProvider } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { queryClient } from '../lib/queryClient';
import { queryPersister } from '../lib/queryPersister';
import '../lib/networkMode'; // Подключаем мониторинг сети
export default function RootLayout() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
// Кэш валиден 24 часа
maxAge: 24 * 60 * 60 * 1000,
// Быстрее восстанавливаем данные
buster: 'v1',
}}
>
<Stack screenOptions={{ headerShown: false }} />
</PersistQueryClientProvider>
);
}
Теперь при запуске приложения TanStack Query мгновенно подхватит кэш из MMKV, отобразит данные пользователю, а затем фоново обновит устаревшие данные с сервера. Никаких пустых экранов или спиннеров, даже без интернета. Пользователи это оценят.
Совмещение Zustand и TanStack Query: практический пример
Ладно, давайте соберём всё вместе. Вот реальный сценарий — экран каталога с фильтрами и корзиной. Фильтры (клиентское состояние) живут в Zustand, данные каталога (серверное состояние) — в TanStack Query.
// stores/useFilterStore.ts
import { create } from 'zustand';
interface FilterState {
category: string | null;
sortBy: 'price_asc' | 'price_desc' | 'name' | 'popular';
priceRange: { min: number; max: number };
searchQuery: string;
setCategory: (category: string | null) => void;
setSortBy: (sortBy: FilterState['sortBy']) => void;
setPriceRange: (range: { min: number; max: number }) => void;
setSearchQuery: (query: string) => void;
resetFilters: () => void;
}
const defaultFilters = {
category: null,
sortBy: 'popular' as const,
priceRange: { min: 0, max: 100000 },
searchQuery: '',
};
export const useFilterStore = create<FilterState>((set) => ({
...defaultFilters,
setCategory: (category) => set({ category }),
setSortBy: (sortBy) => set({ sortBy }),
setPriceRange: (priceRange) => set({ priceRange }),
setSearchQuery: (searchQuery) => set({ searchQuery }),
resetFilters: () => set(defaultFilters),
}));
// hooks/useFilteredProducts.ts
import { useQuery } from '@tanstack/react-query';
import { useFilterStore } from '../stores/useFilterStore';
import { useShallow } from 'zustand/react/shallow';
export function useFilteredProducts() {
const { category, sortBy, priceRange, searchQuery } = useFilterStore(
useShallow((state) => ({
category: state.category,
sortBy: state.sortBy,
priceRange: state.priceRange,
searchQuery: state.searchQuery,
}))
);
return useQuery({
queryKey: [
'products',
'filtered',
{ category, sortBy, priceRange, searchQuery },
],
queryFn: async () => {
const params = new URLSearchParams();
if (category) params.append('category', category);
params.append('sort', sortBy);
params.append('min_price', String(priceRange.min));
params.append('max_price', String(priceRange.max));
if (searchQuery) params.append('q', searchQuery);
const response = await fetch(
`https://api.example.com/v1/products?${params}`
);
if (!response.ok) throw new Error('Ошибка загрузки');
return response.json();
},
// Не делаем запрос, пока пользователь вводит поисковый запрос
enabled: searchQuery.length === 0 || searchQuery.length >= 3,
});
}
// screens/CatalogScreen.tsx
import React from 'react';
import {
FlatList,
View,
Text,
TextInput,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
} from 'react-native';
import { useFilteredProducts } from '../hooks/useFilteredProducts';
import { useFilterStore } from '../stores/useFilterStore';
import { useCartStore } from '../stores/useCartStore';
const CATEGORIES = [
{ id: null, label: 'Все' },
{ id: 'electronics', label: 'Электроника' },
{ id: 'clothing', label: 'Одежда' },
{ id: 'home', label: 'Дом' },
];
export default function CatalogScreen() {
const { data: products, isLoading } = useFilteredProducts();
const { category, setCategory, searchQuery, setSearchQuery } =
useFilterStore();
const addItem = useCartStore((state) => state.addItem);
return (
<View style={styles.container}>
{/* Поиск */}
<TextInput
style={styles.search}
placeholder="Поиск товаров..."
value={searchQuery}
onChangeText={setSearchQuery}
/>
{/* Фильтр по категориям */}
<View style={styles.categories}>
{CATEGORIES.map((cat) => (
<TouchableOpacity
key={cat.id ?? 'all'}
style={[
styles.chip,
category === cat.id && styles.chipActive,
]}
onPress={() => setCategory(cat.id)}
>
<Text
style={[
styles.chipText,
category === cat.id && styles.chipTextActive,
]}
>
{cat.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Список товаров */}
{isLoading ? (
<ActivityIndicator size="large" style={styles.loader} />
) : (
<FlatList
data={products}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.productCard}>
<View>
<Text style={styles.productName}>{item.name}</Text>
<Text style={styles.productPrice}>
{item.price} ₽
</Text>
</View>
<TouchableOpacity
style={styles.addButton}
onPress={() => addItem(item)}
>
<Text style={styles.addButtonText}>В корзину</Text>
</TouchableOpacity>
</View>
)}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f9fafb' },
search: {
margin: 16,
padding: 12,
backgroundColor: '#fff',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
fontSize: 16,
},
categories: {
flexDirection: 'row',
paddingHorizontal: 16,
gap: 8,
marginBottom: 16,
},
chip: {
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 20,
backgroundColor: '#e5e7eb',
},
chipActive: { backgroundColor: '#6366f1' },
chipText: { fontSize: 14, color: '#374151' },
chipTextActive: { color: '#fff' },
loader: { marginTop: 40 },
productCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
marginHorizontal: 16,
marginBottom: 8,
backgroundColor: '#fff',
borderRadius: 12,
},
productName: { fontSize: 16, fontWeight: '600' },
productPrice: { fontSize: 14, color: '#6366f1', marginTop: 4 },
addButton: {
backgroundColor: '#6366f1',
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 8,
},
addButtonText: { color: '#fff', fontWeight: '600' },
});
Видите, как чётко разделены ответственности? Zustand управляет UI-фильтрами, TanStack Query — данными с сервера. Когда пользователь меняет фильтр, queryKey обновляется, и TanStack Query автоматически делает новый запрос или достаёт данные из кэша. Всё работает как часы.
Паттерны и лучшие практики
1. Query Key Factory
Организуйте ключи запросов через фабричные функции — это предотвращает неприятные баги при инвалидации кэша:
// queryKeys.ts
export const queryKeys = {
products: {
all: ['products'] as const,
lists: () => [...queryKeys.products.all, 'list'] as const,
list: (filters: ProductFilters) =>
[...queryKeys.products.lists(), filters] as const,
details: () => [...queryKeys.products.all, 'detail'] as const,
detail: (id: string) =>
[...queryKeys.products.details(), id] as const,
},
users: {
all: ['users'] as const,
me: () => [...queryKeys.users.all, 'me'] as const,
detail: (id: string) =>
[...queryKeys.users.all, 'detail', id] as const,
},
orders: {
all: ['orders'] as const,
list: (status?: string) =>
[...queryKeys.orders.all, 'list', { status }] as const,
detail: (id: string) =>
[...queryKeys.orders.all, 'detail', id] as const,
},
};
Теперь инвалидация предсказуема: вызов invalidateQueries({ queryKey: queryKeys.products.all }) сбросит все запросы, связанные с товарами — и списки, и детали. Без сюрпризов.
2. Не смешивайте серверное и клиентское состояние
Это, пожалуй, самая распространённая ошибка. Не делайте так:
// ПЛОХО: серверные данные в Zustand
const useProductStore = create((set) => ({
products: [],
isLoading: false,
fetchProducts: async () => {
set({ isLoading: true });
const data = await fetch('/api/products').then((r) => r.json());
set({ products: data, isLoading: false });
},
}));
Выглядит просто, но вы теряете автоматическое кэширование, фоновое обновление, ретраи, инвалидацию и вообще всё то, что TanStack Query даёт бесплатно. Не изобретайте велосипед.
3. Используйте select для трансформации данных
Вместо трансформации данных прямо в компоненте, используйте опцию select в useQuery. Это стабилизирует ссылку и предотвращает лишние ререндеры:
function useProductNames() {
return useQuery({
queryKey: productKeys.lists(),
queryFn: fetchProducts,
select: (products) => products.map((p) => p.name),
});
}
4. Подписывайтесь только на нужные поля Zustand
Zustand ререндерит компонент при любом изменении подписанных данных. Подписывайтесь только на то, что реально нужно конкретному компоненту:
// ПЛОХО: подписка на весь стор
const store = useCartStore();
// ХОРОШО: подписка на конкретное поле
const itemCount = useCartStore((state) => state.items.length);
// ХОРОШО: подписка на несколько полей с useShallow
const { items, addItem } = useCartStore(
useShallow((s) => ({ items: s.items, addItem: s.addItem }))
);
5. Типобезопасность
TypeScript — ваш лучший друг в этом стеке. И Zustand, и TanStack Query имеют отличную поддержку типов. Используйте дженерики при создании сторов и строго типизируйте API-ответы:
// Типы API-ответов
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
// Типизированный хук
function useProductsPaginated(page: number) {
return useQuery<PaginatedResponse<Product>>({
queryKey: ['products', 'paginated', page],
queryFn: () =>
fetch(`/api/products?page=${page}&size=20`)
.then((r) => r.json()),
});
}
Бесконечные списки с useInfiniteQuery
Для ленты новостей, каталогов или любых списков с пагинацией у TanStack Query есть useInfiniteQuery. Это, честно, одна из моих любимых фич:
// hooks/useInfiniteProducts.ts
import { useInfiniteQuery } from '@tanstack/react-query';
export function useInfiniteProducts() {
return useInfiniteQuery({
queryKey: ['products', 'infinite'],
queryFn: async ({ pageParam }) => {
const response = await fetch(
`https://api.example.com/v1/products?cursor=${pageParam}&limit=20`
);
if (!response.ok) throw new Error('Ошибка загрузки');
return response.json();
},
initialPageParam: '',
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextCursor : undefined,
});
}
// Использование с FlatList
function InfiniteProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteProducts();
const allProducts = data?.pages.flatMap((page) => page.data) ?? [];
return (
<FlatList
data={allProducts}
keyExtractor={(item) => item.id}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? <ActivityIndicator /> : null
}
renderItem={({ item }) => (
<Text>{item.name}</Text>
)}
/>
);
}
Тестирование
Одно из приятных преимуществ раздельного подхода — тестирование становится значительно проще. Zustand-сторы тестируются как обычные функции, без всяких танцев с бубном:
// __tests__/useCartStore.test.ts
import { useCartStore } from '../stores/useCartStore';
describe('useCartStore', () => {
beforeEach(() => {
// Сбрасываем стор перед каждым тестом
useCartStore.setState({ items: [] });
});
it('добавляет товар в корзину', () => {
const product = { id: '1', name: 'Тест', price: 100 };
useCartStore.getState().addItem(product);
expect(useCartStore.getState().items).toHaveLength(1);
expect(useCartStore.getState().items[0].name).toBe('Тест');
});
it('увеличивает количество при повторном добавлении', () => {
const product = { id: '1', name: 'Тест', price: 100 };
useCartStore.getState().addItem(product);
useCartStore.getState().addItem(product);
expect(useCartStore.getState().items).toHaveLength(1);
expect(useCartStore.getState().items[0].quantity).toBe(2);
});
it('считает итоговую цену', () => {
useCartStore.getState().addItem({ id: '1', name: 'A', price: 100 });
useCartStore.getState().addItem({ id: '2', name: 'B', price: 200 });
expect(useCartStore.getState().getTotalPrice()).toBe(300);
});
});
Для TanStack Query используйте QueryClientProvider в тестовой обёртке и msw (Mock Service Worker) для мокирования API-вызовов.
Заключение: шпаргалка по выбору инструмента
Итак, подведём итоги. Вот простая шпаргалка, которую можно сохранить себе:
- Локальное состояние компонента (видимость модалки, значение инпута) →
useState/useReducer - Глобальное клиентское состояние (тема, авторизация, корзина, онбординг) → Zustand
- Серверные данные (API, кэш, синхронизация) → TanStack Query
- Персистенция (сохранение между сессиями) → MMKV (для Zustand через persist, для TanStack Query через persister)
- Реактивность между деревьями (передача между навигаторами) → Zustand (без Provider, работает глобально)
Этот стек покрывает 95% потребностей реальных приложений. Он проверен в продакшене, хорошо типизирован, быстр и прост в поддержке. Zustand v5 с MMKV обеспечивает молниеносную персистенцию клиентского состояния, TanStack Query v5 берёт на себя всю сложность работы с API, а MMKV даёт синхронный доступ к хранилищу без тормозов AsyncStorage.
Не бойтесь использовать несколько инструментов — каждый решает свою задачу лучше, чем один универсальный. В конце концов, лучший стейт-менеджер — тот, который не заставляет вас о нём думать.