Список из API в React Native строят на FlatList: пара refreshing + onRefresh даёт жест «потяни, чтобы обновить», а onEndReached — догрузку следующей страницы. Чтобы экран не ломался в реальной сети, к этому добавляют отдельные состояния загрузки, ошибки и пустого списка, синхронный флаг против повторной догрузки, отмену устаревших запросов через AbortController и отбрасывание дублей по id. Ниже — готовые хук и экран для Expo SDK 57 и девять тестов к ним.
После материала вы сможете
- подключить
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 г.
Содержание
Задача и состояния экрана
Экран «Услуги» загружает каталог с сервера страницами по 20 записей. Пользователь тянет список вниз, чтобы обновить его, и прокручивает до конца, чтобы подгрузить ещё. В уроке о работе с API и хранением данных разобраны fetch, кэш и одна страница; этот рецепт закрывает то, чего там нет: постраничную догрузку и гонки между запросами.
Первая загрузка
- Что видит пользователь
- Индикатор и «Загружаем услуги…»
- Откуда берётся
- Данных ещё нет, запрос в пути
Ошибка без данных
- Что видит пользователь
- Текст ошибки и «Повторить»
- Откуда берётся
- Первый запрос упал
Пусто
- Что видит пользователь
- «Услуг пока нет»
- Откуда берётся
- Сервер вернул пустой
items
Обновление
- Что видит пользователь
- Системный индикатор над списком
- Откуда берётся
- Жест pull-to-refresh
Ошибка обновления
- Что видит пользователь
- Плашка над прежним списком
- Откуда берётся
- Данные уже есть, их не стираем
Догрузка
- Что видит пользователь
- Индикатор в подвале списка
- Откуда берётся
- Прокрутка дошла до порога
onEndReached
Ошибка догрузки
- Что видит пользователь
- Текст и «Повторить» в подвале
- Откуда берётся
- Следующая страница не пришла
Конец данных
- Что видит пользователь
- «Это все услуги»
- Откуда берётся
- Сервер вернул
nextCursor: null
Сервер отдаёт страницу и курсор следующей — непрозрачную строку, которую клиент возвращает в запросе как есть. Курсорная пагинация устойчивее нумерации страниц: если между запросами в начало каталога добавили запись, страница по номеру «съедет» и покажет одну запись дважды, а курсор продолжит ровно с места остановки. Дубли на стыке всё равно отбрасываем — сервер бывает разным.
{
"items": [
{ "id": "41", "title": "Лендинг", "price": 250000 },
{ "id": "42", "title": "Интернет-магазин", "price": 900000 }
],
"nextCursor": "eyJpZCI6IjQyIn0"
}Готовый код: запрос, хук и экран
Код разделён на три файла. Запрос знает адрес и формат ответа, хук usePagedList — только курсор, состояния и отмену, экран — только отрисовку. Хук не зависит от «услуг» и подойдёт для заказов или новостей.
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, сигнал отмены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 };
}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 },
});onEndReachedвызывается один раз для текущей длины контента, но срабатывает снова, когда длина меняется (в подвале появился индикатор) или пользователь отъехал от края и вернулся.busyRef— синхронный флаг:setModeприменится только при следующем рендере, а проверка поrefне пропустит второй запрос той же страницы сразу.- Каждый запрос получает свой
AbortController, а новый запрос отменяет предыдущий. Pull-to-refresh во время догрузки не ждёт её, а обрывает. Послеawaitхук ещё раз проверяетsignal.aborted: ответ, который успел прийти после отмены, в список не попадёт. - После ошибки догрузки
loadMoreмолчит, пока пользователь не нажмёт «Повторить». Иначе каждое касание прокрутки у конца списка отправляло бы новый запрос в неработающую сеть. - Ошибка обновления не стирает список: данные остаются, сверху появляется плашка. Экран ошибки — только когда показать нечего.
- Функция
fetchPageдолжна быть стабильной: объявите её вне компонента, какfetchServicesPage, или оберните вuseCallback. Новая функция на каждом рендере перезапустит эффект и первую загрузку.
Как подключить
Создайте проект или откройте свой
Рецепт проверен на шаблоне
blank-typescript. Новых зависимостей для списка не нужно:FlatList,RefreshControlиfetchвходят в React Native.Добавьте безопасную зону
SafeAreaViewиз самого React Native помечен устаревшим, документация советуетreact-native-safe-area-context. В Expo Router провайдер уже подключён, в чистом шаблоне его ставят черезnpx expo install.Задайте адрес API
Переменные
EXPO_PUBLIC_*Expo встраивает в бандл открытым текстом. Адрес API туда класть можно, секретные ключи — нет.Скопируйте три файла и покажите экран
Положите файлы из предыдущего раздела в
src/и отрисуйтеServicesListв корне приложения или в маршруте Expo Router.
npx create-expo-app@latest services-app --template blank-typescript
cd services-app
npx expo install react-native-safe-area-contextEXPO_PUBLIC_API_URL=https://api.example.comimport { 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.
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev--dev — флаг Expo CLI, пакеты попадут в 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)"
]
}
}{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"types": ["jest"]
}
}@types: без types команда tsc не узнает test и expectimport { 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);
});$ 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!Тесты проверены «от противного»: если убрать из хука 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.