In React Native a list from an API is built on FlatList: the refreshing + onRefresh pair adds the pull-to-refresh gesture, and onEndReached loads the next page. To survive a real network you also need separate loading, error and empty states, a synchronous flag against duplicate load-more calls, cancellation of stale requests with AbortController, and de-duplication by id. Below is a ready hook and screen for Expo SDK 57 with nine tests.
After this you will be able to
- connect a
FlatListto an API with separate states for first load, error, empty list and end of data - add pull-to-refresh with
refreshingandonRefreshand page loading withonEndReachedwithout repeated requests - cancel stale requests with
AbortControllerand drop duplicates at page boundaries - verify the screen without a device:
tsc, ninejest-expotests with a mockedfetchandexpo export
Know beforehand
Verified with
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)
Checked against docs: October 12, 2026
Contents
The task and the screen states
The "Services" screen loads a catalog from the server in pages of 20. The user pulls the list down to refresh it and scrolls to the end to load more. The lesson on APIs and storage covers fetch, caching and a single page; this recipe covers what it leaves out: paging and races between requests.
First load
- What the user sees
- A spinner and "Loading services…"
- Where it comes from
- No data yet, the request is in flight
Error, no data
- What the user sees
- The error text and "Retry"
- Where it comes from
- The first request failed
Empty
- What the user sees
- "No services yet"
- Where it comes from
- The server returned an empty
items
Refreshing
- What the user sees
- The system indicator above the list
- Where it comes from
- The pull-to-refresh gesture
Refresh error
- What the user sees
- A banner above the old list
- Where it comes from
- Data is already there, so we keep it
Loading more
- What the user sees
- A spinner in the list footer
- Where it comes from
- Scrolling crossed the
onEndReachedthreshold
Load-more error
- What the user sees
- A message and "Retry" in the footer
- Where it comes from
- The next page did not arrive
End of data
- What the user sees
- "No more services"
- Where it comes from
- The server returned
nextCursor: null
The server returns a page and the cursor of the next one, an opaque string the client sends back as is. Cursor pagination is more robust than page numbers: if a record is inserted at the top between requests, a numbered page shifts and shows a record twice, while a cursor continues exactly where it stopped. We still drop duplicates at the boundary, because servers differ.
{
"items": [
{ "id": "41", "title": "Landing page", "price": 250000 },
{ "id": "42", "title": "Online store", "price": 900000 }
],
"nextCursor": "eyJpZCI6IjQyIn0"
}The code: request, hook and screen
The code is split into three files. The request knows the URL and response format, the usePagedList hook knows only the cursor, states and cancellation, and the screen only renders. The hook knows nothing about "services" and works for orders or news just as well.
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 does not reject on 404/500, so check the status yourself
if (!response.ok) throw new HttpError(response.status);
return (await response.json()) as Page<Service>;
}response.ok check, abort signalimport { useCallback, useEffect, useRef, useState } from 'react';
// one page of results and the cursor of the next one (null means no more)
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';
// the data may shift between requests, so never show the same record twice
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); // first page or refresh
const [moreError, setMoreError] = useState<unknown>(null); // loading more
const [hasMore, setHasMore] = useState(false);
const cursorRef = useRef<string | null>(null);
const requestRef = useRef<AbortController | null>(null);
// a synchronous flag: two onEndReached calls can arrive before state updates
const busyRef = useRef(false);
const run = useCallback(
async (next: Mode) => {
requestRef.current?.abort(); // a refresh cancels loading more instead of waiting for it
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; // a stale response must not overwrite a newer one
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(); // the screen is gone, so is the need for the request
requestRef.current = null;
};
}, [run]);
const refresh = useCallback(() => run('refreshing'), [run]);
const retry = useCallback(() => run('loading'), [run]);
const loadMore = useCallback(() => {
// after a load-more error wait for "Retry", or scrolling will loop requests
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 `Server responded with ${e.status}`;
return 'Cannot reach the server';
}
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}>from {item.price.toLocaleString('en-US')} KZT</Text>
</View>
);
});
// outside the component: the same function on every render
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="Loading" />
<Text style={styles.muted}>Loading services…</Text>
</View>
);
}
if (error && items.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.error}>{errorText(error)}</Text>
<Button title="Retry" onPress={retry} />
</View>
);
}
let footer = null;
if (mode === 'loadingMore') {
footer = <ActivityIndicator style={styles.footer} accessibilityLabel="Loading more" />;
} else if (moreError) {
footer = (
<View style={styles.footer}>
<Text style={styles.error}>Could not load more: {errorText(moreError)}</Text>
<Button title="Retry" onPress={retryMore} />
</View>
);
} else if (!hasMore && items.length > 0) {
footer = <Text style={[styles.muted, styles.footer]}>No more services</Text>;
}
return (
<FlatList
testID="services-list"
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={
error ? <Text style={styles.banner}>{errorText(error)}. Showing earlier data</Text> : null
}
ListEmptyComponent={<Text style={[styles.muted, styles.footer]}>No services yet</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 },
});onEndReachedis called once per content length, but fires again when the length changes (a spinner appears in the footer) or the user scrolls away from the edge and back.busyRefis a synchronous flag:setModeonly takes effect on the next render, while arefcheck stops a second request for the same page immediately.- Every request gets its own
AbortController, and a new request cancels the previous one. A pull-to-refresh during load-more does not wait for it, it aborts it. Afterawaitthe hook checkssignal.abortedonce more, so a response that arrives after cancellation never reaches the list. - After a load-more error,
loadMorestays silent until the user taps "Retry". Otherwise every scroll touch near the end would send another request into a dead network. - A refresh error does not wipe the list: the data stays and a banner appears on top. The full error screen is only for when there is nothing to show.
fetchPagemust be stable: declare it outside the component likefetchServicesPage, or wrap it inuseCallback. A new function on every render restarts the effect and the first load.
How to plug it in
Create a project or open yours
The recipe is verified on the
blank-typescripttemplate. The list needs no new dependencies:FlatList,RefreshControlandfetchship with React Native.Add a safe area
React Native's own
SafeAreaViewis deprecated, and the docs point toreact-native-safe-area-context. Expo Router already sets up the provider; in the blank template install it withnpx expo install.Set the API URL
Expo inlines
EXPO_PUBLIC_*variables into the bundle as plain text. An API URL is fine there, secret keys are not.Copy the three files and render the screen
Put the files from the previous section into
src/and renderServicesListat the app root or in an Expo Router route.
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>
);
}Performance of a long list
FlatList does not mount every row, only a "window" around the visible area; off-screen rows are rendered asynchronously, so a very fast scroll can briefly show blank space. The React Native docs list the settings that affect this. The recipe already does the main thing: the row is wrapped in memo, and renderItem and keyExtractor are declared outside the component so they are not recreated on every render.
onEndReachedThreshold
- Default
2, two screens before the end- When to change it
- The recipe uses
0.5: loading starts half a screen before the end. Higher means earlier, with a risk of extra requests
initialNumToRender
- Default
10- When to change it
- If 10 rows do not fill a tablet screen, the first frame shows blank space
windowSize
- Default
21: 10 screens above, 10 below and the current one- When to change it
- Lower saves memory but makes blank areas more likely
maxToRenderPerBatch
- Default
10- When to change it
- Higher means fewer blank areas but a busier JS thread and slower response to taps
getItemLayout
- Default
- not set
- When to change it
- Only when every row has the same height. With dynamic font size the height changes and the calculation becomes wrong
extraData
- Default
- not set
- When to change it
- When
renderItemdepends on something other thandata, such as a selectedid.FlatListis aPureComponentand will not re-render without it
In our experience a list is slow more often because of heavy rows than because of settings: full-size images instead of thumbnails, nested containers, computations in renderItem. Make the row lighter first and tune the numbers after.
How to verify
All state logic can be checked without a phone: the jest-expo preset mocks the native part of the Expo SDK, and fetch is replaced with jest.fn() in tests. For scrolling and the refresh gesture, React Native Testing Library 14.1 has user.scrollTo and user.pullToRefresh; to make FlatList "see" the end, pass contentSize and layoutMeasurement to scrollTo.
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev--dev is an Expo CLI flag, the packages go to 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 packages by default: without types, tsc does not know test and 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: `Service ${n}`, price: 10000 * n }));
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
// a response the test releases by hand, to observe the intermediate state
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('first load: spinner, then the list', async () => {
const first = deferred();
fetchMock.mockReturnValueOnce(first.promise);
await render(<ServicesList />);
expect(screen.getByText('Loading services…')).toBeOnTheScreen();
first.resolve(ok({ items: items(1, 2), nextCursor: null }));
expect(await screen.findByText('Service 1')).toBeOnTheScreen();
expect(screen.getByText('No more services')).toBeOnTheScreen();
expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/v1/services?limit=20');
});
test('empty response → empty state', async () => {
fetchMock.mockResolvedValueOnce(ok({ items: [], nextCursor: null }));
await render(<ServicesList />);
expect(await screen.findByText('No services yet')).toBeOnTheScreen();
});
test('first load error → Retry', async () => {
fetchMock
.mockRejectedValueOnce(new TypeError('Network request failed'))
.mockResolvedValueOnce(ok({ items: items(1), nextCursor: null }));
await render(<ServicesList />);
expect(await screen.findByText('Cannot reach the server')).toBeOnTheScreen();
await fireEvent.press(screen.getByText('Retry'));
expect(await screen.findByText('Service 1')).toBeOnTheScreen();
});
test('scrolling to the end loads the next page by cursor, without duplicates', 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('Service 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('No more services')).toBeOnTheScreen();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toContain('cursor=c2');
expect(screen.getAllByText('Service 3')).toHaveLength(1); // the duplicate at the page boundary is dropped
expect(screen.getByText('Service 5')).toBeOnTheScreen();
});
test('a second onEndReached while loading sends no second request', async () => {
const second = deferred();
fetchMock
.mockResolvedValueOnce(ok({ items: items(1, 2), nextCursor: 'c2' }))
.mockReturnValueOnce(second.promise);
await render(<ServicesList />);
await screen.findByText('Service 2');
const list = screen.getByTestId('services-list');
await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
expect(fetchMock).toHaveBeenCalledTimes(2); // first page + one load-more
second.resolve(ok({ items: items(3), nextCursor: null }));
expect(await screen.findByText('Service 3')).toBeOnTheScreen();
});
test('pull-to-refresh replaces the list, a refresh error keeps the data', 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('Service 1');
const user = userEvent.setup();
await user.pullToRefresh(screen.getByTestId('services-list'));
expect(await screen.findByText('Service 7')).toBeOnTheScreen();
expect(screen.queryByText('Service 1')).not.toBeOnTheScreen();
await user.pullToRefresh(screen.getByTestId('services-list'));
expect(
await screen.findByText('Server responded with 500. Showing earlier data'),
).toBeOnTheScreen();
expect(screen.getByText('Service 7')).toBeOnTheScreen();
});
test('a refresh cancels loading more, its late response never reaches the list', 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('Service 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('Service 8')).toBeOnTheScreen();
expect(fetchMock.mock.calls[1][1].signal.aborted).toBe(true);
await act(async () => {
stale.resolve(ok({ items: items(99), nextCursor: null })); // the response arrives after the cancel
});
expect(screen.queryByText('Service 99')).not.toBeOnTheScreen();
});
test('load-more error: a footer button, scrolling does not retry by itself', 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('Service 1');
const list = screen.getByTestId('services-list');
await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
expect(await screen.findByText('Could not load more: Cannot reach the server')).toBeOnTheScreen();
await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
expect(fetchMock).toHaveBeenCalledTimes(2); // waits for an explicit Retry
await fireEvent.press(screen.getByText('Retry'));
expect(await screen.findByText('Service 2')).toBeOnTheScreen();
});
test('leaving the screen cancels the request', 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
√ first load: spinner, then the list (426 ms)
√ empty response → empty state (3 ms)
√ first load error → Retry (27 ms)
√ scrolling to the end loads the next page by cursor, without duplicates (109 ms)
√ a second onEndReached while loading sends no second request (60 ms)
√ pull-to-refresh replaces the list, a refresh error keeps the data (31 ms)
√ a refresh cancels loading more, its late response never reaches the list (14 ms)
√ load-more error: a footer button, scrolling does not retry by itself (7 ms)
√ leaving the screen cancels the request (1 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!The tests were checked in reverse: remove busyRef, the signal.aborted check or mergeById from the hook and the matching test fails. They do not replace a device, so run through a short list on a phone.
- Turn on airplane mode and open the screen: an error and "Retry"; after reconnecting, the list
- Pull the list down: the refresh indicator shows and disappears after the response
- Scroll to the end several times in a row: the server log shows one load-more per page
- Turn off the network before scrolling to the end: an error in the footer, scrolling sends no requests, "Retry" loads the page
- Leave the screen while loading: the request is cancelled, no warnings in the console
- Turn on VoiceOver or TalkBack: a row is read as a whole, spinners are announced by their label
Limitations and alternatives
- No cache across launches and no offline mode: after a restart the list loads again. Saving the last response in AsyncStorage is shown in the lesson on APIs and storage.
- No automatic retries with backoff, only a manual "Retry". Automatic retries with exponential delay make sense when failures are short-lived and the server can take it.
- Cancellation only works if
fetchPagepassessignalon tofetch. If you use your own HTTP client wrapper, make sure it does not drop it. - With many list screens, TanStack Query (
useInfiniteQuery) handles the same set of tasks: caching, retries, paging, refetch on app focus. It was not verified in this recipe; the hand-written hook helps you understand the mechanics and avoids a dependency for a single screen. - For very long lists with complex rows there are third-party virtualized list implementations. Switch after measuring, not up front.
How this was verified4 · passed1 · not runchecked 12 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.