Fixing React Native Pull-to-Refresh That Triggers Repeatedly or Never Resolves Its Loading Spinner
A pull-to-refresh gesture should show a spinner, fetch fresh data, and let go once the fetch resolves. Instead, the spinner sometimes keeps spinning forever after a successful refresh, or the same pull triggers the refresh handler two or three times in a row. Neither symptom is RefreshControl itself misbehaving — both trace back to the refreshing state and the fetch it's supposed to track quietly falling out of sync.
The Problem
A ScrollView or FlatList is wrapped with a RefreshControl to support the standard pull-to-refresh gesture. Pulling down triggers a data fetch, and the spinner is supposed to disappear once that fetch completes. In practice, two related but distinct symptoms show up: the spinner sometimes keeps spinning indefinitely even after data has visibly refreshed on screen, and on other occasions, a single pull gesture triggers the refresh callback multiple times in quick succession, firing duplicate network requests for what the user experienced as one pull.
Why It Happens
The refreshing prop has to be explicitly set back to false — it doesn't auto-reset when a fetch completes
RefreshControl's refreshing prop is a controlled value the component reads from state; it has no independent knowledge of whether the underlying data fetch has actually finished. If the code that sets refreshing to true on pull doesn't have a corresponding, guaranteed path back to false — especially one that also runs on a fetch error, not just success — the spinner keeps spinning even though the actual data operation is long done.
An error in the refresh handler can skip the state reset entirely if it isn't caught
An async refresh handler that throws partway through — a network error, a parsing failure — and doesn't have a finally block or equivalent catch-and-reset logic leaves refreshing stuck at true forever, because the code path that would have set it back to false never actually executes.
The onRefresh callback identity changing on every render can cause the gesture to be reinterpreted or retriggered
If onRefresh is defined inline as a new function on every render rather than wrapped in useCallback, and something in the render tree responds to that prop changing, the pull-to-refresh interaction can behave inconsistently — including, in some cases, the refresh appearing to fire more than once for what was a single continuous gesture.
Multiple state updates during the refresh (list items changing, re-renders mid-pull) can interact with the gesture recognizer in ways that trigger it again
If the data fetched by the refresh causes a significant layout change while the RefreshControl is still visible or transitioning, the resulting re-render can interact with the gesture in ways that weren't accounted for when the refresh logic was first written, leading to what looks like the same pull firing the handler again.
The Fix
1. Guarantee refreshing is reset to false in a finally block, covering both success and failure
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
try {
await fetchLatestData();
} catch (error) {
reportError(error); // handle the error, but don't let it skip the reset below
} finally {
setRefreshing(false); // always runs, regardless of success or failure
}
}, []);
Placing the state reset in a finally block guarantees it runs whether the fetch succeeds, fails, or throws partway through — removing the specific class of bug where an unhandled error path silently skips the code that would have stopped the spinner.
2. Memoize the onRefresh callback so its identity stays stable across re-renders
const onRefresh = useCallback(async () => {
// ... refresh logic
}, [/* only genuinely necessary dependencies */]);
Wrapping onRefresh in useCallback with a correct dependency array keeps its identity stable between renders unless something it actually depends on changes, removing one potential source of inconsistent gesture-handling behavior tied to prop identity churn.
3. Add a guard against re-entrant refresh calls while one is already in progress
const onRefresh = useCallback(async () => {
if (refreshing) return; // ignore a re-trigger while a refresh is already running
setRefreshing(true);
try {
await fetchLatestData();
} finally {
setRefreshing(false);
}
}, [refreshing]);
Explicitly checking whether a refresh is already in progress before starting another one makes the handler idempotent against being called more than once in quick succession, regardless of what specifically triggered the duplicate call — treating the symptom's actual effect, not just chasing down each possible cause of the retrigger.
4. Avoid disruptive layout changes while the RefreshControl is visible, or defer them until it's dismissed
const onRefresh = useCallback(async () => {
setRefreshing(true);
const newData = await fetchLatestData();
// Apply the data update, then let RefreshControl's own dismiss animation
// complete before anything else shifts layout significantly
setData(newData);
setRefreshing(false);
}, []);
Sequencing the data update and the refreshing state change together, rather than letting other layout-affecting updates interleave with the refresh gesture's own animation, reduces the chance of the gesture recognizer misinterpreting a layout shift as a new pull.
Why This Works
Each fix closes a different gap between what RefreshControl's refreshing prop assumes and what the actual async data flow guarantees. A finally block ensures the spinner always stops regardless of how the fetch resolves; a memoized callback removes identity-related inconsistency in how the gesture is handled across renders; an explicit re-entrancy guard makes duplicate triggers harmless rather than trying to prevent every possible cause of them; and controlling layout changes during the refresh keeps the gesture recognizer from encountering conditions it wasn't designed to handle mid-interaction.
Conclusion
A pull-to-refresh spinner that never stops or fires multiple times isn't RefreshControl misbehaving — it's the refreshing state and the fetch it represents falling out of sync, usually through an unhandled error path or an unstable callback identity. Reset the refreshing state in a finally block so both success and failure paths are covered, memoize the onRefresh callback, guard against re-entrant calls while a refresh is already running, and keep layout changes during the refresh from interfering with the gesture itself.
