The minimum that actually works
import { useCallback, useState } from 'react';
import { FlatList, RefreshControl, Text, View } from 'react-native';
export default function FeedScreen() {
const [items, setItems] = useState([]);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
try {
const next = await fetchFeed();
setItems(next);
} catch (err) {
// surface it — do not swallow
console.warn('refresh failed', err);
} finally {
setRefreshing(false); // <- the line people forget
}
}, []);
return (
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row item={item} />}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#fb923c" // iOS
colors={['#fb923c']} // Android
progressBackgroundColor="#1c1c1c" // Android
/>
}
/>
);
}Two details carry the whole thing. The reset lives in finally, not after the await — a throw anywhere in the try block otherwise leaves refreshing stuck at true forever, and the spinner sits there spinning with no way for the user to cancel it. And the colour is set for both platforms, because the props are different and each one is silently ignored by the other OS.
Every prop, and which OS reads it
| Prop | Platform | Notes |
|---|---|---|
refreshing | Both | Required. Your state — RN never sets it for you. |
onRefresh | Both | Fired once per pull past the threshold. |
tintColor | iOS | The spinner colour. Ignored on Android. |
title / titleColor | iOS | Optional label under the spinner. |
colors | Android | Array — Android cycles through them. |
progressBackgroundColor | Android | The circle behind the spinner. |
progressViewOffset | Both | Push the spinner below a sticky header. |
progressViewOffset is the one worth remembering. If your screen has a sticky header or a translucent nav bar, the Android spinner drops behind it and the pull looks broken even though it fired. Offset it by the header height and it reappears.
Refreshing an empty list
The most common real bug: the user opens the app offline, the list is empty, and now they cannot pull to retry — because there is nothing to scroll. A FlatList with no rows has no scrollable area, so the gesture never starts.
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
// let the empty state fill the screen so it can be pulled
contentContainerStyle={items.length === 0 ? { flexGrow: 1 } : undefined}
ListEmptyComponent={
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: 'rgba(255,255,255,0.6)' }}>
Nothing here yet — pull down to refresh.
</Text>
</View>
}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#fb923c" colors={['#fb923c']} />}
/>flexGrow: 1 on the content container only when the list is empty. Apply it unconditionally and you break the scroll on short-but-non-empty lists.
Pull to refresh vs. the initial load
These are different states and conflating them produces the worst version of the UI: a full-screen spinner every time someone pulls, or a refresh spinner on cold start when the screen is blank. Keep two booleans.
const [loading, setLoading] = useState(true); // first paint
const [refreshing, setRefreshing] = useState(false); // user-initiated
if (loading) return <FeedSkeleton />; // never a bare spinner
// ...then RefreshControl only ever reads `refreshing`If you use a data library, it does this for you and you should let it. TanStack Query exposes isLoading for the first fetch and isRefetching for subsequent ones — wire refreshing={isRefetching} and onRefresh={refetch} and delete your own state entirely.
The traps, in the order you’ll hit them
- The spinner never stops. The reset isn’t in
finally, so a thrown fetch strands it. - The spinner is invisible. You set
tintColorand tested on iOS. Android needscolors, and its default is a dark spinner that vanishes on a dark background. - Nothing happens when you pull. The
RefreshControlis on aViewrather than a scrollable, or you passedonRefreshdirectly to the list instead of inside aRefreshControl. - Refresh fires twice. An inline arrow function recreated each render, combined with a parent that re-renders on the state change. Wrap the handler in
useCallback. - It works in Expo Go but not in a build. Nearly always a nested scroll problem rather than the refresh control — a
FlatListinside aScrollView, where the outer one eats the gesture. - The pull feels laggy. The refresh is running an expensive synchronous transform on the response. Move it off the interaction — the gesture and the parse are competing for the same thread.
The shortcut: generate the list, keep the taste
None of the above is a product decision. Which two booleans exist, where the reset goes, which platform reads which colour prop — that’s wiring, and it’s identical in every app that has a feed. The product decisions are what the empty state says and how stale the data is allowed to get.
Describe the screen in ShipNative — “a feed of saved articles that pulls to refresh and shows a friendly empty state offline” — and it generates the list, the refresh control, both platforms’ colours and the empty state, then runs it on your phone so you can feel the pull. A refresh gesture is one of the things a simulator genuinely can’t tell you about.
Build it free
Describe your app in one sentence and have it running on your phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.