React Native Pull to Refresh: Async Lists
By Native Animation · Updated
React Native pull to refresh combines two responsibilities: recognizing a deliberate pull and keeping the refresh state aligned with an asynchronous request. This guide builds a custom pull handle above a virtualized list, with a threshold, a visible Refresh alternative, cancellation, and protection against obsolete responses. It keeps the existing rows visible during loading and failure.
When RefreshControl is enough
Use the built-in React Native 0.88 RefreshControl when the platform spinner fits your design. Attach it to your list through its refreshControl prop, supply refreshing from application state, and start the request in onRefresh. Set refreshing immediately and clear it when that request settles. The native control already participates in scroll recognition; a custom illustration alone rarely justifies replacing that behavior.
Choose a custom gesture when the pull distance must deform artwork, reveal a character, or coordinate a branded content displacement. You then own gesture arbitration, async state, canceled touches, errors, and an accessible alternative. Do not mount a native RefreshControl and a second refresh recognizer on the same interaction.
| State | Trigger | Data behavior |
|---|---|---|
| Idle / pulling | Downward drag while the list is at the top | Keep current rows |
| Armed | Visual pull reaches 80 points | Wait for release |
| Refreshing | Release above threshold or press Refresh | Start one request |
| Success | The current request resolves | Replace rows |
| Error | The current request rejects | Keep rows and offer retry |
| Canceled | Press Cancel or unmount | Abort and ignore obsolete results |
Setup and gesture boundary
Target versions: Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Gesture Handler 3.3. Install compatible dependencies with:
npx expo install react-native-gesture-handler react-native-reanimated react-native-workletsUse bunx instead of npx in a Bun project. Follow the SDK 58 Gesture Handler setup and Reanimated setup. Save the complete example outside src/app and render it from a screen inside your safe-area layout.
The gesture attaches only to the seventy-two-point handle above the FlatList. Pulling a row does not invoke it. This deliberate boundary lets the list keep its normal scrolling without pretending to provide full-surface nested-scroll arbitration. At a positive list offset the handle gesture is disabled; the Refresh button remains available. A full-surface wrapper must instead coordinate with the actual native list gesture, track its offset on the UI thread, and avoid stealing upward or horizontal movement. See the Ghost Pull Refresh integration below for that finished pattern.
Complete custom refresh example
The Gesture.* builder API remains available in Gesture Handler 3.3, but is deprecated in favor of its hook API. The example retains the builders.
import { useEffect, useRef, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withSpring } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
type Row = { id: string; title: string };
type Props = { load: (signal: AbortSignal) => Promise<Row[]> };
const THRESHOLD = 80;
export function RefreshList({ load }: Props) {
const [rows, setRows] = useState<Row[]>([]);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState('Pull the handle or press Refresh.');
const [atTop, setAtTop] = useState(true);
const request = useRef<AbortController | null>(null);
const alive = useRef(true);
const pull = useSharedValue(0);
const reduced = useReducedMotion();
const settle = () => pull.set(reduced ? 0 : withSpring(0, { damping: 24, stiffness: 260 }));
useEffect(() => {
alive.current = true;
return () => { alive.current = false; request.current?.abort(); request.current = null; };
}, []);
async function refresh() {
if (request.current) return;
const controller = new AbortController();
request.current = controller;
setBusy(true);
setStatus('Refreshing…');
try {
const next = await load(controller.signal);
if (alive.current && request.current === controller && !controller.signal.aborted) {
setRows(next);
setStatus('List updated.');
}
} catch {
if (alive.current && request.current === controller && !controller.signal.aborted) {
setStatus('Refresh failed. Your previous rows are still here. Try again.');
}
} finally {
if (alive.current && request.current === controller) {
request.current = null;
setBusy(false);
settle();
}
}
}
function cancel() {
request.current?.abort();
request.current = null;
setBusy(false);
setStatus('Refresh canceled.');
settle();
}
const pan = Gesture.Pan().enabled(atTop && !busy).activeOffsetY(8).failOffsetX([-24, 24])
.onUpdate((event) => { pull.set(Math.min(120, Math.max(0, event.translationY * 0.65))); })
.onEnd((_event, success) => {
if (!success) return;
if (pull.get() >= THRESHOLD) {
pull.set(reduced ? 0 : THRESHOLD);
scheduleOnRN(refresh);
} else {
pull.set(reduced ? 0 : withSpring(0, { damping: 24, stiffness: 260 }));
}
})
.onFinalize((_event, success) => {
if (!success) pull.set(reduced ? 0 : withSpring(0));
});
const indicator = useAnimatedStyle(() => ({
opacity: 0.5 + Math.min(1, pull.get() / THRESHOLD) * 0.5,
transform: [{ translateY: reduced ? 0 : pull.get() * 0.25 }],
}));
return (
<View style={styles.root}>
<GestureDetector gesture={pan}>
<View style={styles.handle} collapsable={false}>
<Animated.Text style={indicator}>Pull down here · release after the marker brightens</Animated.Text>
</View>
</GestureDetector>
<Text accessibilityLiveRegion="polite" accessibilityState={{ busy }}>{status}</Text>
<View style={styles.actions}>
<Pressable accessibilityRole="button" accessibilityLabel="Refresh list"
accessibilityState={{ disabled: busy, busy }} disabled={busy} onPress={() => void refresh()}
style={styles.button}><Text>Refresh</Text></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel="Cancel refresh"
accessibilityState={{ disabled: !busy }} disabled={!busy} onPress={cancel}
style={styles.button}><Text>Cancel</Text></Pressable>
</View>
<FlatList data={rows} keyExtractor={(row) => row.id}
renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
onScroll={(event) => setAtTop(event.nativeEvent.contentOffset.y <= 0)} scrollEventThrottle={16}
ListEmptyComponent={<Text>No rows yet. Press Refresh to load the list.</Text>} />
</View>
);
}
// Deterministic local data source; replace with your own abortable request.
async function loadRows(signal: AbortSignal): Promise<Row[]> {
await new Promise<void>((resolve, reject) => {
const stop = () => { clearTimeout(timer); reject(new Error('Canceled')); };
const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve(); }, 700);
if (signal.aborted) stop();
else signal.addEventListener('abort', stop, { once: true });
});
return [{ id: 'first', title: 'Updated trail conditions' }, { id: 'second', title: 'New route notes' }];
}
export default function RefreshExample() {
return <GestureHandlerRootView style={{ flex: 1 }}><RefreshList load={loadRows} /></GestureHandlerRootView>;
}
const styles = StyleSheet.create({
root: { flex: 1, padding: 24, gap: 12, backgroundColor: '#FFFFFF' },
handle: { minHeight: 72, justifyContent: 'center', backgroundColor: '#EDE9FE', padding: 12 },
actions: { flexDirection: 'row', gap: 12 },
button: { minWidth: 48, minHeight: 48, padding: 12, justifyContent: 'center', borderWidth: 1, borderRadius: 12 },
row: { paddingVertical: 24, color: '#24163D' },
});Threshold, request ownership, and errors
The gesture converts finger distance to a damped pull with a factor of 0.65 and clamps it at 120 points. Arming at 80 points therefore takes about 123 points of finger travel. The indicator reaches full opacity at the threshold. Release below it returns to rest; a canceled recognizer also settles without starting work. Shared values drive the visual feedback; only the release crosses to JavaScript through scheduleOnRN.
The ref guards against duplicate requests before React has rendered busy. The AbortController object is also a request identity: after Cancel, an old promise cannot replace rows, clear a newer busy state, or change its status. Aborting saves work only when your data source observes the signal. Identity checks still protect the UI when it does not. The host must apply the same checks around any cache or store writes inside its loader.
A failed refresh preserves the old rows and exposes visible retry copy. There is no fake success flourish on failure. Add a timeout in your transport layer if requests can hang. For paginated lists, invalidate or cancel old page requests too; a late page from the previous snapshot must not append to the refreshed dataset.
Accessibility and motion
Refresh and Cancel are named, forty-eight-point buttons with disabled states, so dragging is optional. Status text uses a polite live region and busy state; test announcement behavior in your host and use a discrete native announcement if needed, rather than announcing every pull increment. Keep each row separately accessible and preserve stable IDs across refreshes.
Reduced Motion removes the handle displacement and spring return; opacity still communicates progress. The Reanimated useReducedMotion hook reads the startup preference. Subscribe to AccessibilityInfo reduceMotionChanged and cancel ongoing animations if your product needs live preference changes. Native refresh spinner styling and gesture behavior differ by platform; web touch behavior also depends on the browser's own page overscroll.
Finished pull-to-refresh pieces
Elastic Pull Refresh adds a Skia Bézier sheet, content displacement, an async onRefresh lifecycle, threshold customization, and a completion treatment. Its documented integration wraps scrollable children. It does not expose a request-abort API; request cancellation remains host work.
Ghost Pull Refresh adds original procedural character artwork, resisted overshoot, success and error paths, an onError callback, and a refresh() ref method. For a virtualized list, use its renderList binding with Animated.FlatList and the supplied onScroll; do not add another ScrollView around it. Ordinary non-scrollable children use the piece's own scroll view. Its host still owns network cancellation and stale data protection.
Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. These components provide interaction and presentation; your application supplies data, authorization, retries, and persistence. Explore loading examples, the skeleton loader guide, and progress feedback for related states.
FAQ
Why does my refresh indicator disappear immediately?
The controlled refreshing flag must become true when work starts and stay true until that request settles. Starting a promise without updating the flag leaves the native control idle.
Can I cancel a refresh by releasing the gesture?
Releasing below the threshold cancels the pull. Releasing above it starts the request. After that, Cancel is a separate explicit action; interrupting a visual spring is not network cancellation.
Does the tutorial pull anywhere on the list?
No. It uses a dedicated handle above the list. Full-surface pulling requires shared scroll-offset tracking and simultaneous native-gesture coordination, as documented by Ghost Pull Refresh.