React Native loads server data with the same fetch as the browser, but a screen has to survive a bad network: it needs loading, error, empty and “cached data” states. Settings and cache go into AsyncStorage — an unencrypted string store — while tokens and other secrets belong in expo-secure-store, which encrypts values with the iOS Keychain and the Android Keystore. Once you have many requests, TanStack Query replaces the hand-written code, and large relational data goes into SQLite.
After this you will be able to
- Load a list with
fetch, checkingresponse.ok, cancelling viaAbortControllerand handling loading, error and empty states - Render the list in a
FlatListwith pull-to-refresh and keep on-screen data when the network fails - Store settings and a cache in AsyncStorage and the token in
expo-secure-store, and explain the difference - Test storage logic and screen states with
jest-expo, without a device
Know beforehand
Verified with
Expo SDK 57 (expo 57.0.27)React Native 0.86.3React 19.2.3TypeScript 6.0.3@react-native-async-storage/async-storage 2.2.0expo-secure-store 57.0.4@tanstack/react-query 5.104.2expo-network 57.0.2jest-expo 57.0.5 + Jest 29.7@testing-library/react-native 14.1.0Node.js 24 LTS (24.18.0)
Checked against docs: October 11, 2026
Contents
- Networking on a phone: five states instead of two
- The API client: fetch, response status and cancellation
- The list screen: FlatList and every state
- AsyncStorage: settings and cache
- SecureStore: tokens and secrets
- TanStack Query and SQLite: when hand-written code isn't enough
- Verification: types, tests and the bundle build
Networking on a phone: five states instead of two
A mobile app loses its connection in elevators and on the subway, switches between Wi-Fi and cellular, and gets reopened from the background hours later. A screen that simply waits for the server and renders a list breaks in practice: an endless spinner, a blank screen, or an error on top of data that loaded a minute ago.
First load
- What the user sees
- A spinner and “Loading orders…”
- When it happens
- No data in memory or in the cache
Data
- What the user sees
- The list
- When it happens
- The server responded and the response is cached
Empty
- What the user sees
- “No orders yet”
- When it happens
- The server returned an empty array
Error
- What the user sees
- A clear message and a Retry button
- When it happens
- No network or a server error, and nothing cached
Stale data
- What the user sees
- The list plus a banner “Showing data from 10:42”
- When it happens
- The network failed but the cache holds a previous response
The request itself is familiar: the React Native docs state that networking uses the Fetch API. There are three differences from the browser. Native apps have no CORS, so the server doesn't need to allow the app's “origin”. iOS requires HTTPS by default (App Transport Security), and Android blocks cleartext HTTP from API level 28, so an http:// test server simply won't answer. Finally, anything in the JavaScript bundle can be extracted from the installed app.
The lesson builds one “My orders” screen: a list from the server, a cache for offline use, settings and an auth token. The project is the blank-typescript template from create-expo-app. Install packages with npx expo install: it picks versions compatible with the current Expo SDK, so on SDK 57 it installs AsyncStorage 2.2.0 rather than the latest 3.x from npm.
npx create-expo-app@latest orders-app --template blank-typescript
cd orders-app
npx expo install @react-native-async-storage/async-storage expo-secure-store
npx expo install @tanstack/react-query expo-networkThe API client: fetch, response status and cancellation
The fetch rule people forget: the promise rejects only on a network failure. If the server answers 401, 404 or 500, fetch resolves normally, and MDN tells you to check response.ok yourself. So every request goes through one wrapper that attaches the token, checks the status and turns an HTTP error into an exception carrying the code.
import { getToken } from '../storage/token';
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com';
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
export async function apiGet<T>(path: string, signal?: AbortSignal): Promise<T> {
const token = await getToken();
const response = await fetch(`${API_URL}${path}`, {
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal,
});
if (!response.ok) {
// fetch не бросает исключение на 401/404/500 — проверяем статус сами
throw new ApiError(response.status, `HTTP ${response.status}`);
}
return (await response.json()) as T;
}import { apiGet } from './client';
export type OrderStatus = 'new' | 'in_progress' | 'done';
export type Order = {
id: string;
title: string;
status: OrderStatus;
updatedAt: string; // ISO 8601
};
export function fetchOrders(signal?: AbortSignal): Promise<Order[]> {
return apiGet<Order[]>('/v1/orders', signal);
}
const STATUS_LABEL: Record<OrderStatus, string> = {
new: 'Новый',
in_progress: 'В работе',
done: 'Готов',
};
export function formatStatus(status: OrderStatus): string {
return STATUS_LABEL[status];
}ApiErrorkeepsstatus, so the screen can tell “session expired” (401) from “server down” (5xx) and from no network (a plainTypeErrorfromfetch).- The
signalfrom anAbortControllercancels the request if the user leaves the screen or pulls to refresh while the old request is still running. - The token is read from SecureStore on every request, so a stale token doesn't linger in memory after sign-out.
as Tis a promise to the compiler, not a check. In production, validate the response with a schema (Zod, for example): after a backend update the format may change.
The list screen: FlatList and every state
React Native renders lists with FlatList: it draws only the visible rows instead of a thousand items at once. It ships with everything this screen needs: keyExtractor for stable keys, ListEmptyComponent for the empty state, and the refreshing + onRefresh pair that adds the standard pull-to-refresh gesture.
import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Button, FlatList, StyleSheet, Text, View } from 'react-native';
import { ApiError } from '../api/client';
import { fetchOrders, formatStatus, type Order } from '../api/orders';
import { readCache, writeCache } from '../storage/cache';
const CACHE_KEY = 'orders';
const MAX_AGE_MS = 5 * 60 * 1000; // кэш младше 5 минут считаем свежим
function errorText(e: unknown): string {
if (e instanceof ApiError && e.status === 401) return 'Сессия истекла — войдите снова';
if (e instanceof ApiError) return `Сервер ответил ошибкой ${e.status}`;
return 'Нет соединения с сервером';
}
function timeOf(ts: number): string {
return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
export default function OrdersScreen() {
const [orders, setOrders] = useState<Order[] | null>(null); // null — показать пока нечего
const [status, setStatus] = useState<'loading' | 'refreshing' | 'idle'>('loading');
const [error, setError] = useState<string | null>(null);
const [cachedAt, setCachedAt] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback(async (mode: 'initial' | 'refresh') => {
abortRef.current?.abort(); // отменяем предыдущий запрос, если он ещё идёт
const controller = new AbortController();
abortRef.current = controller;
setStatus(mode === 'refresh' ? 'refreshing' : 'loading');
setError(null);
if (mode === 'initial') {
const hit = await readCache<Order[]>(CACHE_KEY, MAX_AGE_MS);
if (controller.signal.aborted) return;
if (hit) {
setOrders(hit.data); // сначала показываем сохранённое — экран не пустой
setCachedAt(hit.savedAt);
if (!hit.stale) {
setStatus('idle');
return;
}
}
}
try {
const fresh = await fetchOrders(controller.signal);
setOrders(fresh);
setCachedAt(null);
await writeCache(CACHE_KEY, fresh);
} catch (e) {
if (!controller.signal.aborted) setError(errorText(e));
} finally {
if (!controller.signal.aborted) setStatus('idle');
}
}, []);
useEffect(() => {
load('initial');
return () => abortRef.current?.abort();
}, [load]);
if (orders === null && status === 'loading') {
return (
<View style={styles.center}>
<ActivityIndicator size="large" />
<Text style={styles.muted}>Загружаем заказы…</Text>
</View>
);
}
if (orders === null && error) {
return (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
<Button title="Повторить" onPress={() => load('initial')} />
</View>
);
}
return (
<FlatList
data={orders ?? []}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.muted}>{formatStatus(item.status)}</Text>
</View>
)}
ListHeaderComponent={
error && cachedAt ? (
<Text style={styles.banner}>
{error}. Показаны данные от {timeOf(cachedAt)}
</Text>
) : null
}
ListEmptyComponent={<Text style={styles.muted}>Заказов пока нет</Text>}
refreshing={status === 'refreshing'}
onRefresh={() => load('refresh')}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, padding: 24 },
row: { paddingVertical: 12, paddingHorizontal: 16, borderBottomWidth: StyleSheet.hairlineWidth },
title: { fontSize: 16, fontWeight: '600' },
muted: { color: '#666', padding: 16 },
error: { color: '#b00020', textAlign: 'center' },
banner: { backgroundColor: '#fff4ce', padding: 12 },
});Cancel the old request
If the user refreshes twice, the first response must not overwrite the second.
Show the cache
On first open the screen immediately renders the saved list — no blank screen, no spinner.
Decide whether to hit the network
A cache younger than five minutes counts as fresh and no request is sent. Pull-to-refresh always goes to the network.
Load and save
A fresh response replaces the list and is written to the cache together with a timestamp.
Survive the error
If data is already on screen, the error appears as a banner above the list, not instead of it.
AsyncStorage: settings and cache
AsyncStorage is an asynchronous, unencrypted, persistent key-value store. Values are strings only, so objects are saved with JSON.stringify and read with JSON.parse. On Android data lives in SQLite, on iOS in files inside the app sandbox. The v2 docs list Android limits: about 6 MB for the whole store by default and about 2 MB per entry.
import AsyncStorage from '@react-native-async-storage/async-storage';
export type Settings = {
theme: 'system' | 'light' | 'dark';
showDone: boolean;
};
export const DEFAULT_SETTINGS: Settings = { theme: 'system', showDone: true };
const SETTINGS_KEY = 'settings:v1';
export async function loadSettings(): Promise<Settings> {
try {
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
if (raw === null) return DEFAULT_SETTINGS;
// старая версия приложения могла сохранить не все поля — дополняем умолчаниями
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) };
} catch {
return DEFAULT_SETTINGS; // повреждённый JSON не должен ронять приложение
}
}
export async function saveSettings(patch: Partial<Settings>): Promise<Settings> {
const next = { ...(await loadSettings()), ...patch };
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(next));
return next;
}import AsyncStorage from '@react-native-async-storage/async-storage';
type Entry<T> = { savedAt: number; data: T };
export type CacheHit<T> = { data: T; savedAt: number; stale: boolean };
export async function writeCache<T>(key: string, data: T): Promise<void> {
const entry: Entry<T> = { savedAt: Date.now(), data };
await AsyncStorage.setItem(`cache:${key}`, JSON.stringify(entry));
}
export async function readCache<T>(key: string, maxAgeMs: number): Promise<CacheHit<T> | null> {
try {
const raw = await AsyncStorage.getItem(`cache:${key}`);
if (raw === null) return null;
const entry = JSON.parse(raw) as Entry<T>;
return { ...entry, stale: Date.now() - entry.savedAt > maxAgeMs };
} catch {
return null;
}
}Note the :v1 suffix in the settings key and the merge with DEFAULT_SETTINGS: the app gets updated, but the data on the phone stays from the previous version. A new field must get a default, and broken JSON must not crash the app on startup.
SecureStore: tokens and secrets
expo-secure-store encrypts key-value pairs: on Android values are stored in SharedPreferences, encrypted with a key from the Android Keystore; on iOS they go to the Keychain. It is meant for small values: tokens, keys, secrets. Keys may contain only letters, digits, dots, hyphens and underscores. SecureStore does not support the web: the docs list only Android, iOS and tvOS, so the web version of an app stores the token differently — usually in an httpOnly cookie set by the server.
import * as SecureStore from 'expo-secure-store';
// ключ: только буквы, цифры, «.», «-» и «_»
const TOKEN_KEY = 'auth.accessToken';
export function saveToken(token: string): Promise<void> {
return SecureStore.setItemAsync(TOKEN_KEY, token);
}
export function getToken(): Promise<string | null> {
return SecureStore.getItemAsync(TOKEN_KEY);
}
export function clearToken(): Promise<void> {
return SecureStore.deleteItemAsync(TOKEN_KEY);
}AsyncStorage
- Does not encrypt data
- Settings, response cache, drafts, an “onboarding done” flag
- Works on Android, iOS and the web
- Fine for hundreds of kilobytes
SecureStore
- Encrypts via the Keychain and the Android Keystore
- Access and refresh tokens, the key for an encrypted local database
- Android, iOS and tvOS, no web
- Small values only: some iOS releases rejected entries above ~2048 bytes
- On iOS a Keychain entry survives app deletion if the app is reinstalled with the same bundle ID. The Expo docs say not to rely on this, but don't be surprised by a “resurrected” token either — validate it with the server on first launch.
- On Android SecureStore data is removed with the app. The
expo-secure-storeconfig plugin by default configures Android Auto Backup to skip the encrypted entries, since they can't be decrypted after a restore anyway. - SecureStore is not a place for the only copy of important data: the server remains the source of truth.
TanStack Query and SQLite: when hand-written code isn't enough
Writing the manual screen once is worth it to understand the mechanics. With a dozen screens that fetch data, repeating cancellation, caching, retries and refresh-on-resume in each one gets expensive. According to its docs, TanStack Query works in React Native out of the box, but it doesn't know two things: when the network is gone and when the app returns from the background. You wire those up with onlineManager and focusManager.
import { AppState, Platform, type AppStateStatus } from 'react-native';
import * as Network from 'expo-network';
import { focusManager, onlineManager, QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 2 } },
});
// сеть пропала/вернулась → TanStack Query приостанавливает и возобновляет запросы
onlineManager.setEventListener((setOnline) => {
const subscription = Network.addNetworkStateListener((state) => {
setOnline(!!state.isConnected);
});
return subscription.remove;
});
// приложение вернулось из фона → устаревшие запросы обновятся
export function onAppStateChange(status: AppStateStatus) {
if (Platform.OS !== 'web') focusManager.setFocused(status === 'active');
}
export function subscribeAppState() {
const subscription = AppState.addEventListener('change', onAppStateChange);
return () => subscription.remove();
}import { useQuery } from '@tanstack/react-query';
import { ActivityIndicator, Button, FlatList, Text, View } from 'react-native';
import { fetchOrders, formatStatus } from '../api/orders';
export default function OrdersQueryScreen() {
const { data, error, isPending, isRefetching, refetch } = useQuery({
queryKey: ['orders'],
queryFn: ({ signal }) => fetchOrders(signal), // signal отменит запрос при уходе с экрана
staleTime: 5 * 60 * 1000,
});
if (isPending) return <ActivityIndicator size="large" />;
if (error && !data) {
return (
<View>
<Text>Не удалось загрузить заказы</Text>
<Button title="Повторить" onPress={() => refetch()} />
</View>
);
}
return (
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Text>
{item.title} — {formatStatus(item.status)}
</Text>
)}
ListEmptyComponent={<Text>Заказов пока нет</Text>}
refreshing={isRefetching}
onRefresh={() => refetch()}
/>
);
}In the root App.tsx, wrap the screen in QueryClientProvider client={queryClient} and call subscribeAppState() once in an Effect. The TanStack Query cache lives in memory and is empty after an app restart. To persist it across launches the library offers the createAsyncStoragePersister plugin; it was not tested in this lesson.
Theme, language, onboarding flags
- Where
- AsyncStorage
- Why
- Little data, no encryption needed
The last list response for offline use
- Where
- AsyncStorage or the TanStack Query cache
- Why
- A small JSON with a timestamp
Access and refresh tokens
- Where
- expo-secure-store
- Why
- A secret; needs the encrypted Keychain/Keystore
Thousands of records, search, filters, relations
- Where
- expo-sqlite
- Why
- You need queries and indexes, not reading the whole JSON
Photos, PDFs, downloaded files
- Where
- expo-file-system
- Why
- Binary data doesn't belong in string stores
In our experience, moving to SQLite pays off when the app must fully work offline: create records without a network and send them later. Then you need a local database with a queue of changes rather than a response cache — a separate architectural task.
Verification: types, tests and the bundle build
Storage logic and every screen state can be tested without a phone. Expo recommends the jest-expo preset, which mocks the native part of the Expo SDK, plus React Native Testing Library for components. AsyncStorage ships its own in-memory mock, and in tests SecureStore is replaced with a Map.
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev--dev is an Expo CLI flag that puts them into devDependencies{
"scripts": {
"test": "jest"
},
"jest": {
"preset": "jest-expo",
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
]
}
}module.exports = require('@react-native-async-storage/async-storage/jest/async-storage-mock');import AsyncStorage from '@react-native-async-storage/async-storage';
import { DEFAULT_SETTINGS, loadSettings, saveSettings } from '../src/storage/settings';
import { readCache, writeCache } from '../src/storage/cache';
import { clearToken, getToken, saveToken } from '../src/storage/token';
// SecureStore — нативный модуль: в тестах подменяем его хранилищем в памяти
jest.mock('expo-secure-store', () => {
const store = new Map<string, string>();
return {
setItemAsync: jest.fn(async (k: string, v: string) => void store.set(k, v)),
getItemAsync: jest.fn(async (k: string) => store.get(k) ?? null),
deleteItemAsync: jest.fn(async (k: string) => void store.delete(k)),
};
});
beforeEach(async () => {
await AsyncStorage.clear();
});
test('настройки: по умолчанию, слияние и повреждённый JSON', async () => {
expect(await loadSettings()).toEqual(DEFAULT_SETTINGS);
await saveSettings({ theme: 'dark' });
expect(await loadSettings()).toEqual({ theme: 'dark', showDone: true });
await AsyncStorage.setItem('settings:v1', '{oops');
expect(await loadSettings()).toEqual(DEFAULT_SETTINGS);
});
test('кэш становится устаревшим по времени', async () => {
const now = jest.spyOn(Date, 'now').mockReturnValue(1_000_000);
await writeCache('orders', [{ id: '1' }]);
expect(await readCache('orders', 60_000)).toMatchObject({ stale: false, data: [{ id: '1' }] });
now.mockReturnValue(1_000_000 + 61_000);
expect((await readCache('orders', 60_000))?.stale).toBe(true);
expect(await readCache('missing', 60_000)).toBeNull();
now.mockRestore();
});
test('токен живёт в SecureStore, а не в AsyncStorage', async () => {
await saveToken('test-token');
expect(await getToken()).toBe('test-token');
expect(await AsyncStorage.getAllKeys()).toEqual([]);
await clearToken();
expect(await getToken()).toBeNull();
});import AsyncStorage from '@react-native-async-storage/async-storage';
import { fireEvent, render, screen } from '@testing-library/react-native';
import OrdersScreen from '../src/screens/OrdersScreen';
import { writeCache } from '../src/storage/cache';
jest.mock('expo-secure-store', () => ({ getItemAsync: jest.fn(async () => null) }));
const orders = [
{ id: '1', title: 'Заказ №1', status: 'new', updatedAt: '2026-10-11T09:00:00Z' },
{ id: '2', title: 'Заказ №2', status: 'done', updatedAt: '2026-10-11T10:00:00Z' },
];
function mockFetchOnce(body: unknown, status = 200) {
(globalThis.fetch as jest.Mock).mockResolvedValueOnce({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
}
beforeEach(async () => {
await AsyncStorage.clear();
globalThis.fetch = jest.fn();
});
test('загрузка → список, ответ кэшируется', async () => {
let respond!: (value: unknown) => void;
(globalThis.fetch as jest.Mock).mockReturnValueOnce(new Promise((r) => (respond = r)));
await render(<OrdersScreen />);
expect(screen.getByText('Загружаем заказы…')).toBeOnTheScreen(); // ответа ещё нет
respond({ ok: true, status: 200, json: async () => orders });
expect(await screen.findByText('Заказ №1')).toBeOnTheScreen();
expect(screen.getByText('Готов')).toBeOnTheScreen();
expect(await AsyncStorage.getItem('cache:orders')).toContain('Заказ №2');
});
test('пустой ответ → пустое состояние', async () => {
mockFetchOnce([]);
await render(<OrdersScreen />);
expect(await screen.findByText('Заказов пока нет')).toBeOnTheScreen();
});
test('ошибка без кэша → сообщение и «Повторить»', async () => {
(globalThis.fetch as jest.Mock).mockRejectedValueOnce(new TypeError('Network request failed'));
mockFetchOnce(orders);
await render(<OrdersScreen />);
expect(await screen.findByText('Нет соединения с сервером')).toBeOnTheScreen();
await fireEvent.press(screen.getByText('Повторить'));
expect(await screen.findByText('Заказ №1')).toBeOnTheScreen();
});
test('HTTP 500 при устаревшем кэше → данные из кэша и плашка', async () => {
const now = jest.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 9, 11, 8, 0));
await writeCache('orders', orders);
now.mockReturnValue(Date.UTC(2026, 9, 11, 9, 0)); // прошёл час — кэш устарел
mockFetchOnce({ message: 'boom' }, 500);
await render(<OrdersScreen />);
expect(await screen.findByText(/Сервер ответил ошибкой 500/)).toBeOnTheScreen();
expect(screen.getByText('Заказ №1')).toBeOnTheScreen();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
now.mockRestore();
});
test('свежий кэш → сеть не трогаем', async () => {
await writeCache('orders', orders);
await render(<OrdersScreen />);
expect(await screen.findByText('Заказ №2')).toBeOnTheScreen();
expect(globalThis.fetch).not.toHaveBeenCalled();
});render and fireEvent are async — await them$ npx tsc --noEmit
$ npx jest --verbose
PASS __tests__/storage.test.ts
√ настройки: по умолчанию, слияние и повреждённый JSON (10 ms)
√ кэш становится устаревшим по времени (1 ms)
√ токен живёт в SecureStore, а не в AsyncStorage (1 ms)
PASS __tests__/OrdersScreen.test.tsx
√ загрузка → список, ответ кэшируется (913 ms)
√ пустой ответ → пустое состояние (18 ms)
√ ошибка без кэша → сообщение и «Повторить» (93 ms)
√ HTTP 500 при устаревшем кэше → данные из кэша и плашка (62 ms)
√ свежий кэш → сеть не трогаем (8 ms)
Tests: 8 passed, 8 total
$ npx expo export --platform android --platform ios
Android Bundled 13969ms index.ts (644 modules)
iOS Bundled 17830ms index.ts (646 modules)
Exported: dist
$ npx expo-doctor
21/21 checks passed. No issues detected!expo export builds the JavaScript bundles for Android and iOS without a device, which catches broken imports and incompatible packages. Running on a phone, a real network and Keychain/Keystore behaviour are not covered by this check: test those in Expo Go or a development build on a device.
How this was verified4 · passed1 · partial1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.