How to buildOctober 11, 20268 min read
React Native + ExpoStep 5 of 9

React Native: API requests and local storage

In short

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.

TutorialLevel: IntermediateState ManagementPractice: ~1.5–3 h

After this you will be able to

  • Load a list with fetch, checking response.ok, cancelling via AbortController and handling loading, error and empty states
  • Render the list in a FlatList with 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

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
  1. 01Networking on a phone: five states instead of two
  2. 02The API client: fetch, response status and cancellation
  3. 03The list screen: FlatList and every state
  4. 04AsyncStorage: settings and cache
  5. 05SecureStore: tokens and secrets
  6. 06TanStack Query and SQLite: when hand-written code isn't enough
  7. 07Verification: 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.

bash
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-network
Project and lesson dependencies

The 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.

src/api/client.ts
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;
}
A single entry point for all requests: token, status check, cancellation via AbortSignal
src/api/orders.ts
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];
}
The data type, the list request and status formatting
  • ApiError keeps status, so the screen can tell “session expired” (401) from “server down” (5xx) and from no network (a plain TypeError from fetch).
  • The signal from an AbortController cancels 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 T is 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.

src/screens/OrdersScreen.tsx
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 },
});
A screen with all five states, a cache and pull-to-refresh
  1. Cancel the old request

    If the user refreshes twice, the first response must not overwrite the second.

  2. Show the cache

    On first open the screen immediately renders the saved list — no blank screen, no spinner.

  3. 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.

  4. Load and save

    A fresh response replaces the list and is written to the cache together with a timestamp.

  5. 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.

src/storage/settings.ts
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;
}
Settings: defaults, merging with an older version, protection against corrupted JSON
src/storage/cache.ts
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;
  }
}
A response cache with a timestamp: the data and a “stale” flag

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.

src/storage/token.ts
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);
}
The access token lives only in SecureStore

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-store config 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.

src/query.ts
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();
}
The query client: network status from expo-network, focus from AppState
src/screens/OrdersQueryScreen.tsx
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()}
    />
  );
}
The same screen with useQuery: the library handles cache, retries and cancellation

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.

bash
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev
Test dependencies: --dev is an Expo CLI flag that puts them into devDependencies
package.json
{
  "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)"
    ]
  }
}
package.json fragment: the script and Jest config from the Expo docs
__mocks__/@react-native-async-storage/async-storage.js
module.exports = require('@react-native-async-storage/async-storage/jest/async-storage-mock');
A manual mock that Jest picks up automatically
__tests__/storage.test.ts
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();
});
Settings, cache expiry and the token
__tests__/OrdersScreen.test.tsx
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();
});
Screen states with a mocked fetch. In RNTL 14 render and fireEvent are async — await them
terminal
$ 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!
Checks in a temporary project: types, 8 tests, Android and iOS bundle build, expo-doctor

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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.