Управление состоянием в React Native 2026: Zustand v5, TanStack Query и MMKV

Разбираем современный стек управления состоянием в React Native 2026: Zustand v5, TanStack Query v5 и MMKV. Клиентское и серверное состояние, персистенция, оффлайн-поддержка — с рабочими примерами кода.

Введение: зачем вообще думать об управлении состоянием в 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.

Не бойтесь использовать несколько инструментов — каждый решает свою задачу лучше, чем один универсальный. В конце концов, лучший стейт-менеджер — тот, который не заставляет вас о нём думать.

Об авторе Editorial Team

Our team of expert writers and editors.