Сеть, кэширование и хранение данных на устройстве

Практический блок: откуда берутся данные, где они кэшируются и что остаётся на устройстве после закрытия приложения.

Серверное состояние живёт по своим законам: оно устаревает, приходит с задержкой и может отсутствовать. Поэтому его не хранят как обычное состояние, а кэшируют.

Вопрос

«Как работаете с сетью? Зачем нужен TanStack Query, если есть fetch? Где храните токен и пользовательские настройки?»

Что на самом деле проверяет интервьюер

Понимаете ли вы специфику мобильного приложения: нестабильная сеть, дорогой трафик, выгрузка приложения из памяти, требования безопасности к токенам. В вебе многие из этих вопросов не возникают.

Развёрнутый ответ

Сеть: fetch, axios и что действительно важно

fetch доступен из коробки, axios добавляет перехватчики и удобную отмену. Гораздо важнее уметь назвать сопутствующие вещи: таймауты, повторные попытки, обновление токена, обработка отсутствия сети.

async function apiFetch(path: string, init: RequestInit = {}) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10000); // без таймаута запрос может висеть минутами

  try {
    const res = await fetch(BASE_URL + path, {
      ...init,
      signal: controller.signal,
      headers: { 'Content-Type': 'application/json', ...init.headers },
    });
    if (res.status === 401) return refreshTokenAndRetry(path, init);
    if (!res.ok) throw new ApiError(res.status, await res.text());
    return res.json();
  } finally {
    clearTimeout(timeout);
  }
}

Почему ручной fetch в useEffect не масштабируется

На каждый экран приходится писать одно и то же: состояния loading и error, отмену устаревших запросов, обновление после возврата, повторный запрос при ошибке. Плюс два экрана, которым нужны одни и те же данные, сходят за двумя запросами. Библиотека кэша решает это разом:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function ProfileScreen({ userId }) {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => apiFetch(`/users/${userId}`),
    staleTime: 60_000, // минуту считаем данные свежими и не ходим в сеть
  });

  const queryClient = useQueryClient();
  const rename = useMutation({
    mutationFn: (name) => apiFetch(`/users/${userId}`, {
      method: 'PATCH',
      body: JSON.stringify({ name }),
    }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user', userId] }),
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorView onRetry={refetch} />;
  return <Profile user={data} onRename={rename.mutate} />;
}

Ключевые понятия, которые стоит произнести: queryKey как идентификатор кэша, staleTime и устаревание, инвалидация после мутации, дедупликация одинаковых параллельных запросов, оптимистичное обновление.

Хранение на устройстве

ЧтоЧемОсобенности
Настройки, флаги, черновикиAsyncStorageасинхронный, не шифрует, простой key-value
То же, но быстроMMKVсинхронный через JSI, умеет шифровать
Токены, пароли, ключиKeychain (iOS) и Keystore (Android)системное защищённое хранилище, доступ по биометрии
Структурированные данные, оффлайнSQLite, WatermelonDB, Realmзапросы, индексы, синхронизация
Файлы и картинкифайловая система приложениясистема может чистить каталог кэша
// MMKV: синхронное чтение — можно инициализировать состояние без «мигания» экрана
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV({ id: 'app', encryptionKey: 'secret-from-keychain' });

storage.set('onboardingDone', true);
const done = storage.getBoolean('onboardingDone'); // никаких промисов

Оффлайн и нестабильная сеть

  • Отслеживание соединения — @react-native-community/netinfo: показать баннер, отложить запросы.
  • Персистентный кэш запросов, чтобы при запуске без сети показать последние данные, а не пустой экран.
  • Очередь мутаций: действия пользователя копятся и отправляются, когда сеть вернётся.
  • Идемпотентность на бэкенде — иначе повторная отправка создаст дубль заказа.

Типичные ошибки кандидатов

  • Хранят токен в AsyncStorage и не понимают, чем это отличается от Keychain.
  • Не ставят таймауты: в метро запрос висит, спиннер крутится вечно.
  • Кладут в AsyncStorage мегабайты JSON и удивляются задержкам при старте.
  • Не различают клиентское и серверное состояние — тащат ответы API в глобальный store и вручную пишут инвалидацию.

Как ответить кратко

«Сеть — fetch или axios с обязательным таймаутом через AbortController, перехватом 401 и обновлением токена. Поверх — TanStack Query или RTK Query: они дают кэш по ключу, дедупликацию, статусы, инвалидацию после мутаций и повторные попытки, поэтому руками я это не пишу. На устройстве: обычные настройки — AsyncStorage или, если важна скорость и синхронное чтение, MMKV на JSI; токены только в Keychain и Keystore; структурированные данные и оффлайн — SQLite, WatermelonDB или Realm. Отдельно продумываю оффлайн: netinfo, персистентный кэш и очередь отложенных мутаций.»

Проверьте себя
1. Почему токен доступа не стоит хранить в AsyncStorage?
AAsyncStorage слишком медленный для токенов
BОн не шифруется: на устройстве с root или jailbreak данные читаются как обычный файл — для секретов есть Keychain и Keystore
CAsyncStorage не умеет хранить строки длиннее 256 символов
DТокен в AsyncStorage теряется при перезапуске приложения
2. Что даёт TanStack Query по сравнению с ручным fetch внутри useEffect?
AОн делает запросы быстрее за счёт нативного модуля
BКэш по ключу, дедупликацию одинаковых запросов, статусы загрузки и ошибок, инвалидацию и повторные попытки
CОн заменяет собой навигацию
DОн позволяет обойти CORS
3. Чем MMKV принципиально отличается от AsyncStorage?
AMMKV работает только на Android
BMMKV даёт синхронный доступ через JSI и заметно быстрее, а AsyncStorage асинхронный
CMMKV хранит данные в облаке
DMMKV поддерживает только числа