Как делать12 октября 2026 г.6 мин чтения
React Native + ExpoШаг 6 из 9

Рецепт: список с загрузкой из API и pull-to-refresh в React Native

Коротко

Список из API в React Native строят на FlatList: пара refreshing + onRefresh даёт жест «потяни, чтобы обновить», а onEndReached — догрузку следующей страницы. Чтобы экран не ломался в реальной сети, к этому добавляют отдельные состояния загрузки, ошибки и пустого списка, синхронный флаг против повторной догрузки, отмену устаревших запросов через AbortController и отбрасывание дублей по id. Ниже — готовые хук и экран для Expo SDK 57 и девять тестов к ним.

Рецепт кодаУровень: СреднийРецепты React NativeПрактика: ~20–40 мин

После материала вы сможете

  • подключить FlatList к API с отдельными состояниями первой загрузки, ошибки, пустого списка и конца данных
  • добавить pull-to-refresh через refreshing и onRefresh и догрузку страниц через onEndReached без повторных запросов
  • отменять устаревшие запросы через AbortController и отбрасывать дубли на стыке страниц
  • проверить экран без устройства: tsc, девять тестов jest-expo с подменённым fetch и expo export

Проверено на

Expo SDK 57 (expo 57.0.27)React Native 0.86.3React 19.2.3TypeScript 6.0.3react-native-safe-area-context 5.7.0jest-expo 57.0.5 + Jest 29.7@testing-library/react-native 14.1.0Node.js 24 LTS (24.18.0)

Сверено с документацией: 12 октября 2026 г.

Содержание
  1. 01Задача и состояния экрана
  2. 02Готовый код: запрос, хук и экран
  3. 03Как подключить
  4. 04Производительность длинного списка
  5. 05Как проверить
  6. 06Ограничения и альтернативы

Задача и состояния экрана

Экран «Услуги» загружает каталог с сервера страницами по 20 записей. Пользователь тянет список вниз, чтобы обновить его, и прокручивает до конца, чтобы подгрузить ещё. В уроке о работе с API и хранением данных разобраны fetch, кэш и одна страница; этот рецепт закрывает то, чего там нет: постраничную догрузку и гонки между запросами.

Первая загрузка

Что видит пользователь
Индикатор и «Загружаем услуги…»
Откуда берётся
Данных ещё нет, запрос в пути

Ошибка без данных

Что видит пользователь
Текст ошибки и «Повторить»
Откуда берётся
Первый запрос упал

Пусто

Что видит пользователь
«Услуг пока нет»
Откуда берётся
Сервер вернул пустой items

Обновление

Что видит пользователь
Системный индикатор над списком
Откуда берётся
Жест pull-to-refresh

Ошибка обновления

Что видит пользователь
Плашка над прежним списком
Откуда берётся
Данные уже есть, их не стираем

Догрузка

Что видит пользователь
Индикатор в подвале списка
Откуда берётся
Прокрутка дошла до порога onEndReached

Ошибка догрузки

Что видит пользователь
Текст и «Повторить» в подвале
Откуда берётся
Следующая страница не пришла

Конец данных

Что видит пользователь
«Это все услуги»
Откуда берётся
Сервер вернул nextCursor: null

Сервер отдаёт страницу и курсор следующей — непрозрачную строку, которую клиент возвращает в запросе как есть. Курсорная пагинация устойчивее нумерации страниц: если между запросами в начало каталога добавили запись, страница по номеру «съедет» и покажет одну запись дважды, а курсор продолжит ровно с места остановки. Дубли на стыке всё равно отбрасываем — сервер бывает разным.

GET /v1/services?limit=20
{
  "items": [
    { "id": "41", "title": "Лендинг", "price": 250000 },
    { "id": "42", "title": "Интернет-магазин", "price": 900000 }
  ],
  "nextCursor": "eyJpZCI6IjQyIn0"
}
Контракт ответа, на который рассчитан рецепт (пример данных)

Готовый код: запрос, хук и экран

Код разделён на три файла. Запрос знает адрес и формат ответа, хук usePagedList — только курсор, состояния и отмену, экран — только отрисовку. Хук не зависит от «услуг» и подойдёт для заказов или новостей.

src/api/services.ts
import type { Page } from '../hooks/usePagedList';

const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com';
const PAGE_SIZE = 20;

export type Service = { id: string; title: string; price: number };

export class HttpError extends Error {
  constructor(public readonly status: number) {
    super(`HTTP ${status}`);
    this.name = 'HttpError';
  }
}

export async function fetchServicesPage(
  cursor: string | null,
  signal: AbortSignal,
): Promise<Page<Service>> {
  let query = `limit=${PAGE_SIZE}`;
  if (cursor) query += `&cursor=${encodeURIComponent(cursor)}`;

  const response = await fetch(`${API_URL}/v1/services?${query}`, {
    headers: { Accept: 'application/json' },
    signal,
  });
  // fetch не отклоняет промис на 404/500 — статус проверяем сами
  if (!response.ok) throw new HttpError(response.status);
  return (await response.json()) as Page<Service>;
}
Запрос страницы: курсор, проверка response.ok, сигнал отмены
src/hooks/usePagedList.ts
import { useCallback, useEffect, useRef, useState } from 'react';

// страница ответа и курсор следующей (null — дальше пусто)
export type Page<T> = { items: T[]; nextCursor: string | null };
export type FetchPage<T> = (cursor: string | null, signal: AbortSignal) => Promise<Page<T>>;
type Mode = 'loading' | 'refreshing' | 'loadingMore';

// сервер мог сдвинуть данные между запросами — не показываем одну запись дважды
function mergeById<T extends { id: string }>(prev: T[], next: T[]): T[] {
  const seen = new Set(prev.map((item) => item.id));
  return [...prev, ...next.filter((item) => !seen.has(item.id))];
}

export function usePagedList<T extends { id: string }>(fetchPage: FetchPage<T>) {
  const [items, setItems] = useState<T[]>([]);
  const [mode, setMode] = useState<Mode | 'idle'>('loading');
  const [error, setError] = useState<unknown>(null); // первая страница или обновление
  const [moreError, setMoreError] = useState<unknown>(null); // догрузка
  const [hasMore, setHasMore] = useState(false);

  const cursorRef = useRef<string | null>(null);
  const requestRef = useRef<AbortController | null>(null);
  // синхронный флаг: два onEndReached подряд приходят раньше, чем обновится состояние
  const busyRef = useRef(false);

  const run = useCallback(
    async (next: Mode) => {
      requestRef.current?.abort(); // обновление отменяет догрузку, а не ждёт её
      const controller = new AbortController();
      requestRef.current = controller;
      busyRef.current = true;
      setMode(next);
      const append = next === 'loadingMore';

      try {
        const page = await fetchPage(append ? cursorRef.current : null, controller.signal);
        if (controller.signal.aborted) return; // устаревший ответ не перетирает новый
        setItems((prev) => (append ? mergeById(prev, page.items) : page.items));
        cursorRef.current = page.nextCursor;
        setHasMore(page.nextCursor !== null);
        setError(null);
        setMoreError(null);
      } catch (e) {
        if (controller.signal.aborted) return;
        if (append) setMoreError(e);
        else setError(e);
      } finally {
        if (requestRef.current === controller) {
          requestRef.current = null;
          busyRef.current = false;
          setMode('idle');
        }
      }
    },
    [fetchPage],
  );

  useEffect(() => {
    run('loading');
    return () => {
      requestRef.current?.abort(); // ушли с экрана — запрос больше не нужен
      requestRef.current = null;
    };
  }, [run]);

  const refresh = useCallback(() => run('refreshing'), [run]);
  const retry = useCallback(() => run('loading'), [run]);

  const loadMore = useCallback(() => {
    // после ошибки догрузки ждём кнопку «Повторить», иначе прокрутка зациклит запросы
    if (busyRef.current || cursorRef.current === null || moreError) return;
    run('loadingMore');
  }, [run, moreError]);

  const retryMore = useCallback(() => {
    setMoreError(null);
    run('loadingMore');
  }, [run]);

  return { items, mode, error, moreError, hasMore, refresh, retry, loadMore, retryMore };
}
Хук постраничного списка: обновление, догрузка, отмена и защита от дублей
src/screens/ServicesList.tsx
import { memo } from 'react';
import {
  ActivityIndicator,
  Button,
  FlatList,
  StyleSheet,
  Text,
  View,
  type ListRenderItem,
} from 'react-native';
import { fetchServicesPage, HttpError, type Service } from '../api/services';
import { usePagedList } from '../hooks/usePagedList';

function errorText(e: unknown): string {
  if (e instanceof HttpError) return `Сервер ответил ошибкой ${e.status}`;
  return 'Нет соединения с сервером';
}

const ServiceRow = memo(function ServiceRow({ item }: { item: Service }) {
  return (
    <View style={styles.row} accessible>
      <Text style={styles.title}>{item.title}</Text>
      <Text style={styles.muted}>от {item.price.toLocaleString('ru-RU')} ₸</Text>
    </View>
  );
});

// вне компонента: одна и та же функция между рендерами
const renderItem: ListRenderItem<Service> = ({ item }) => <ServiceRow item={item} />;
const keyExtractor = (item: Service) => item.id;

export default function ServicesList() {
  const { items, mode, error, moreError, hasMore, refresh, retry, loadMore, retryMore } =
    usePagedList(fetchServicesPage);

  if (mode === 'loading' && items.length === 0) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" accessibilityLabel="Загрузка" />
        <Text style={styles.muted}>Загружаем услуги…</Text>
      </View>
    );
  }

  if (error && items.length === 0) {
    return (
      <View style={styles.center}>
        <Text style={styles.error}>{errorText(error)}</Text>
        <Button title="Повторить" onPress={retry} />
      </View>
    );
  }

  let footer = null;
  if (mode === 'loadingMore') {
    footer = <ActivityIndicator style={styles.footer} accessibilityLabel="Загружаем ещё" />;
  } else if (moreError) {
    footer = (
      <View style={styles.footer}>
        <Text style={styles.error}>Не удалось загрузить ещё: {errorText(moreError)}</Text>
        <Button title="Повторить" onPress={retryMore} />
      </View>
    );
  } else if (!hasMore && items.length > 0) {
    footer = <Text style={[styles.muted, styles.footer]}>Это все услуги</Text>;
  }

  return (
    <FlatList
      testID="services-list"
      data={items}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      ListHeaderComponent={
        error ? (
          <Text style={styles.banner}>{errorText(error)}. Показаны прежние данные</Text>
        ) : null
      }
      ListEmptyComponent={<Text style={[styles.muted, styles.footer]}>Услуг пока нет</Text>}
      ListFooterComponent={footer}
      refreshing={mode === 'refreshing'}
      onRefresh={refresh}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
    />
  );
}

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' },
  error: { color: '#b00020', textAlign: 'center' },
  banner: { backgroundColor: '#fff4ce', padding: 12 },
  footer: { padding: 16, alignItems: 'center', gap: 8 },
});
Экран: все состояния из таблицы, pull-to-refresh и догрузка
  • onEndReached вызывается один раз для текущей длины контента, но срабатывает снова, когда длина меняется (в подвале появился индикатор) или пользователь отъехал от края и вернулся. busyRef — синхронный флаг: setMode применится только при следующем рендере, а проверка по ref не пропустит второй запрос той же страницы сразу.
  • Каждый запрос получает свой AbortController, а новый запрос отменяет предыдущий. Pull-to-refresh во время догрузки не ждёт её, а обрывает. После await хук ещё раз проверяет signal.aborted: ответ, который успел прийти после отмены, в список не попадёт.
  • После ошибки догрузки loadMore молчит, пока пользователь не нажмёт «Повторить». Иначе каждое касание прокрутки у конца списка отправляло бы новый запрос в неработающую сеть.
  • Ошибка обновления не стирает список: данные остаются, сверху появляется плашка. Экран ошибки — только когда показать нечего.
  • Функция fetchPage должна быть стабильной: объявите её вне компонента, как fetchServicesPage, или оберните в useCallback. Новая функция на каждом рендере перезапустит эффект и первую загрузку.

Как подключить

  1. Создайте проект или откройте свой

    Рецепт проверен на шаблоне blank-typescript. Новых зависимостей для списка не нужно: FlatList, RefreshControl и fetch входят в React Native.

  2. Добавьте безопасную зону

    SafeAreaView из самого React Native помечен устаревшим, документация советует react-native-safe-area-context. В Expo Router провайдер уже подключён, в чистом шаблоне его ставят через npx expo install.

  3. Задайте адрес API

    Переменные EXPO_PUBLIC_* Expo встраивает в бандл открытым текстом. Адрес API туда класть можно, секретные ключи — нет.

  4. Скопируйте три файла и покажите экран

    Положите файлы из предыдущего раздела в src/ и отрисуйте ServicesList в корне приложения или в маршруте Expo Router.

bash
npx create-expo-app@latest services-app --template blank-typescript
cd services-app
npx expo install react-native-safe-area-context
Проект и единственная дополнительная зависимость
.env
EXPO_PUBLIC_API_URL=https://api.example.com
Адрес API — не секрет, его можно встраивать в приложение
App.tsx
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import ServicesList from './src/screens/ServicesList';

export default function App() {
  return (
    <SafeAreaProvider>
      <SafeAreaView style={{ flex: 1 }}>
        <ServicesList />
      </SafeAreaView>
      <StatusBar style="auto" />
    </SafeAreaProvider>
  );
}
Корень приложения с безопасной зоной

Производительность длинного списка

FlatList монтирует не все строки, а «окно» вокруг видимой области; за кадром строки дорисовываются асинхронно, поэтому при очень быстрой прокрутке можно на мгновение увидеть пустое место. Документация React Native называет настройки, которые на это влияют. В рецепте уже сделано главное: строка обёрнута в memo, renderItem и keyExtractor объявлены вне компонента и не пересоздаются на каждом рендере.

onEndReachedThreshold

По умолчанию
2 — два экрана до конца
Когда трогать
В рецепте 0.5: догрузка начинается за полэкрана до конца. Больше — раньше и с риском лишних запросов

initialNumToRender

По умолчанию
10
Когда трогать
Если 10 строк не закрывают экран планшета — первый кадр покажет пустоту

windowSize

По умолчанию
21 — 10 экранов выше, 10 ниже и текущий
Когда трогать
Меньше — экономия памяти, но больше шанс увидеть пустые области

maxToRenderPerBatch

По умолчанию
10
Когда трогать
Больше — меньше пустых областей, но дольше занят JS-поток и хуже отклик на нажатия

getItemLayout

По умолчанию
не задан
Когда трогать
Только если у всех строк одинаковая высота. С динамическим размером шрифта высота меняется — расчёт станет неверным

extraData

По умолчанию
не задан
Когда трогать
Если renderItem зависит от чего-то кроме data: например, от выбранного id. FlatList — PureComponent и без этого не перерисуется

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

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

Логику всех состояний проверяют без телефона: пресет jest-expo подменяет нативную часть Expo SDK, а fetch в тестах заменяет jest.fn(). Для прокрутки и жеста обновления у React Native Testing Library 14.1 есть user.scrollTo и user.pullToRefresh; чтобы FlatList «увидел» конец, в scrollTo передают contentSize и layoutMeasurement.

bash
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev
Тестовые зависимости: --dev — флаг Expo CLI, пакеты попадут в 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: скрипт и настройка Jest по документации Expo
tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "types": ["jest"]
  }
}
TypeScript 6 по умолчанию не подключает пакеты @types: без types команда tsc не узнает test и expect
__tests__/ServicesList.test.tsx
import { act, fireEvent, render, screen, userEvent } from '@testing-library/react-native';
import ServicesList from '../src/screens/ServicesList';

const items = (...ids: number[]) =>
  ids.map((n) => ({ id: String(n), title: `Услуга ${n}`, price: 10000 * n }));
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });

// ответ, который тест отдаёт вручную, — чтобы увидеть промежуточное состояние
function deferred() {
  let resolve!: (value: unknown) => void;
  const promise = new Promise((r) => (resolve = r));
  return { promise, resolve };
}

const fetchMock = jest.fn();
beforeEach(() => {
  fetchMock.mockReset();
  globalThis.fetch = fetchMock;
});

test('первая загрузка: индикатор, затем список', async () => {
  const first = deferred();
  fetchMock.mockReturnValueOnce(first.promise);
  await render(<ServicesList />);
  expect(screen.getByText('Загружаем услуги…')).toBeOnTheScreen();

  first.resolve(ok({ items: items(1, 2), nextCursor: null }));
  expect(await screen.findByText('Услуга 1')).toBeOnTheScreen();
  expect(screen.getByText('Это все услуги')).toBeOnTheScreen();
  expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/v1/services?limit=20');
});

test('пустой ответ → пустое состояние', async () => {
  fetchMock.mockResolvedValueOnce(ok({ items: [], nextCursor: null }));
  await render(<ServicesList />);
  expect(await screen.findByText('Услуг пока нет')).toBeOnTheScreen();
});

test('ошибка первой загрузки → «Повторить»', async () => {
  fetchMock
    .mockRejectedValueOnce(new TypeError('Network request failed'))
    .mockResolvedValueOnce(ok({ items: items(1), nextCursor: null }));
  await render(<ServicesList />);
  expect(await screen.findByText('Нет соединения с сервером')).toBeOnTheScreen();

  await fireEvent.press(screen.getByText('Повторить'));
  expect(await screen.findByText('Услуга 1')).toBeOnTheScreen();
});

test('прокрутка до конца догружает страницу по курсору без дублей', async () => {
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1, 2, 3), nextCursor: 'c2' }))
    .mockResolvedValueOnce(ok({ items: items(3, 4, 5), nextCursor: null }));
  await render(<ServicesList />);
  await screen.findByText('Услуга 3');

  const user = userEvent.setup();
  await user.scrollTo(screen.getByTestId('services-list'), {
    y: 500,
    contentSize: { width: 400, height: 1000 },
    layoutMeasurement: { width: 400, height: 600 },
  });

  expect(await screen.findByText('Это все услуги')).toBeOnTheScreen();
  expect(fetchMock).toHaveBeenCalledTimes(2);
  expect(fetchMock.mock.calls[1][0]).toContain('cursor=c2');
  expect(screen.getAllByText('Услуга 3')).toHaveLength(1); // дубль на стыке страниц отброшен
  expect(screen.getByText('Услуга 5')).toBeOnTheScreen();
});

test('повторный onEndReached во время загрузки не шлёт второй запрос', async () => {
  const second = deferred();
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1, 2), nextCursor: 'c2' }))
    .mockReturnValueOnce(second.promise);
  await render(<ServicesList />);
  await screen.findByText('Услуга 2');

  const list = screen.getByTestId('services-list');
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  expect(fetchMock).toHaveBeenCalledTimes(2); // первая страница + одна догрузка

  second.resolve(ok({ items: items(3), nextCursor: null }));
  expect(await screen.findByText('Услуга 3')).toBeOnTheScreen();
});

test('pull-to-refresh заменяет список, ошибка обновления сохраняет данные', async () => {
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1, 2), nextCursor: null }))
    .mockResolvedValueOnce(ok({ items: items(7), nextCursor: null }))
    .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) });
  await render(<ServicesList />);
  await screen.findByText('Услуга 1');

  const user = userEvent.setup();
  await user.pullToRefresh(screen.getByTestId('services-list'));
  expect(await screen.findByText('Услуга 7')).toBeOnTheScreen();
  expect(screen.queryByText('Услуга 1')).not.toBeOnTheScreen();

  await user.pullToRefresh(screen.getByTestId('services-list'));
  expect(
    await screen.findByText('Сервер ответил ошибкой 500. Показаны прежние данные'),
  ).toBeOnTheScreen();
  expect(screen.getByText('Услуга 7')).toBeOnTheScreen();
});

test('обновление отменяет догрузку, её поздний ответ не попадает в список', async () => {
  const stale = deferred();
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1), nextCursor: 'c2' }))
    .mockReturnValueOnce(stale.promise)
    .mockResolvedValueOnce(ok({ items: items(8), nextCursor: null }));
  await render(<ServicesList />);
  await screen.findByText('Услуга 1');

  const list = screen.getByTestId('services-list');
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  const user = userEvent.setup();
  await user.pullToRefresh(list);
  expect(await screen.findByText('Услуга 8')).toBeOnTheScreen();
  expect(fetchMock.mock.calls[1][1].signal.aborted).toBe(true);

  await act(async () => {
    stale.resolve(ok({ items: items(99), nextCursor: null })); // ответ пришёл после отмены
  });
  expect(screen.queryByText('Услуга 99')).not.toBeOnTheScreen();
});

test('ошибка догрузки: кнопка в подвале, прокрутка не повторяет запрос сама', async () => {
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1), nextCursor: 'c2' }))
    .mockRejectedValueOnce(new TypeError('Network request failed'))
    .mockResolvedValueOnce(ok({ items: items(2), nextCursor: null }));
  await render(<ServicesList />);
  await screen.findByText('Услуга 1');

  const list = screen.getByTestId('services-list');
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  expect(
    await screen.findByText('Не удалось загрузить ещё: Нет соединения с сервером'),
  ).toBeOnTheScreen();
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  expect(fetchMock).toHaveBeenCalledTimes(2); // ждём явного «Повторить»

  await fireEvent.press(screen.getByText('Повторить'));
  expect(await screen.findByText('Услуга 2')).toBeOnTheScreen();
});

test('уход с экрана отменяет запрос', async () => {
  fetchMock.mockReturnValueOnce(new Promise(() => {}));
  await render(<ServicesList />);
  const { signal } = fetchMock.mock.calls[0][1] as { signal: AbortSignal };
  expect(signal.aborted).toBe(false);

  await screen.unmount();
  expect(signal.aborted).toBe(true);
});
Девять тестов: состояния, курсор, дубли, повторный onEndReached, обновление, отмена
terminal
$ npx tsc --noEmit
$ npx jest --verbose
PASS __tests__/ServicesList.test.tsx
  √ первая загрузка: индикатор, затем список (406 ms)
  √ пустой ответ → пустое состояние (4 ms)
  √ ошибка первой загрузки → «Повторить» (26 ms)
  √ прокрутка до конца догружает страницу по курсору без дублей (82 ms)
  √ повторный onEndReached во время загрузки не шлёт второй запрос (62 ms)
  √ pull-to-refresh заменяет список, ошибка обновления сохраняет данные (31 ms)
  √ обновление отменяет догрузку, её поздний ответ не попадает в список (15 ms)
  √ ошибка догрузки: кнопка в подвале, прокрутка не повторяет запрос сама (6 ms)
  √ уход с экрана отменяет запрос
Tests:       9 passed, 9 total
$ npx expo export --platform android --platform ios
iOS Bundled 3557ms index.ts (590 modules)
Android Bundled 5466ms index.ts (592 modules)
Exported: dist
$ npx expo-doctor
21/21 checks passed. No issues detected!
Прогон во временном проекте: типы, тесты, сборка бандлов Android и iOS, expo-doctor

Тесты проверены «от противного»: если убрать из хука busyRef, проверку signal.aborted или mergeById, падает соответствующий тест. Устройство они не заменяют — на телефоне пройдите короткий список.

  • Включите режим полёта и откройте экран: ошибка и «Повторить», после включения сети — список
  • Потяните список вниз: индикатор обновления виден и исчезает после ответа
  • Прокрутите до конца несколько раз подряд: в журнале сервера одна догрузка на страницу
  • Отключите сеть перед прокруткой до конца: ошибка в подвале, прокрутка не шлёт запросы, «Повторить» догружает
  • Уйдите с экрана во время загрузки: запрос отменяется, предупреждений в консоли нет
  • Включите VoiceOver или TalkBack: строка читается целиком, индикаторы озвучиваются подписью

Ограничения и альтернативы

  • Нет кэша между запусками и офлайн-режима: после перезапуска список загружается заново. Как сохранить последний ответ в AsyncStorage, показано в уроке о работе с API и хранением данных.
  • Нет повторов с паузой при сбое сети — только ручное «Повторить». Автоматические повторы с экспоненциальной задержкой уместны, когда ошибки кратковременны и сервер это выдерживает.
  • Отмена работает, только если fetchPage передаёт signal дальше в fetch. Если вы используете свою обёртку над HTTP-клиентом, проверьте, что она его не теряет.
  • Если экранов со списками много, тот же набор задач — кэш, повторы, догрузка, обновление при возврате в приложение — решает TanStack Query (useInfiniteQuery). В этом рецепте он не проверялся; ручной хук полезен, чтобы понимать механику и не тянуть зависимость ради одного экрана.
  • Для очень длинных списков со сложными строками есть сторонние реализации виртуализированных списков. Переходите на них после замеров, а не заранее.
Как проверено4 · пройдено1 · не запускалосьсверено 12 октября 2026 г.
  • запущено, работает

    src/api/services.ts, src/hooks/usePagedList.ts, src/screens/ServicesList.tsx, App.tsx (RU и EN-варианты строк/комментариев)

    временный проект create-expo-app@5.0.0 (blank-typescript): Expo SDK 57.0.27 / RN 0.86.3 / React 19.2.3; react-native-safe-area-context 5.7.0 через npx expo install; npx tsc --noEmit (TypeScript 6.0.3, tsconfig с types: ["jest"]) без ошибок; файлы отформатированы prettier и скопированы в статью скриптом из проекта

  • запущено, работает

    __tests__/ServicesList.test.tsx (RU и EN)

    jest-expo 57.0.5 + @testing-library/react-native 14.1.0, мок fetch через jest.fn(): 9 тестов RU + 9 тестов EN — passed (первая загрузка, пусто, ошибка+Повторить, user.scrollTo → догрузка по курсору без дублей, повторный endReached, user.pullToRefresh + ошибка обновления, отмена догрузки обновлением, ошибка догрузки без автоповтора, отмена при unmount). Мутационная проверка: без busyRef, без проверки signal.aborted и без mergeById падают соответствующие тесты

  • запущено, работает

    сборка и окружение

    npx expo export --platform android --platform ios — бандлы Hermes собраны (Exported: dist); npx expo-doctor@1.20.4 — 21/21 checks passed; вывод в статье скопирован из прогона

  • запущено, работает

    поведение onEndReached (один раз на длину контента, повторно при изменении длины или возврате к краю)

    сверено с исходником @react-native/virtualized-lists 0.86.3 (VirtualizedList._maybeCallOnEdgeReached, _sentEndForContentLength) в node_modules временного проекта

  • не запускалось

    запуск на устройстве/эмуляторе (Expo Go, development build), реальная сеть, жест обновления вживую, VoiceOver/TalkBack

    устройства и эмуляторы на машине недоступны; ручной чек-лист в статье, поведение сверено с документацией React Native

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.