How do you add pull-to-refresh to a React Native ScrollView with RefreshControl, and why must refreshing be state you set?
answer
- refreshControl prop takes an element
- refreshing is required and controlled
- true on start, false in finally
- vertical ScrollView only
- colors on Android, tintColor on iOS
basics
~20 sPass a RefreshControl element to the ScrollView's refreshControl prop with refreshing and onRefresh. refreshing is a controlled prop: set it to true when onRefresh starts and back to false when the reload ends, or the spinner stops immediately.
solid answer
~40 sYou pass `<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />` to the `ScrollView`'s `refreshControl` prop. When the user pulls down from the top, `onRefresh` fires; the handler sets `refreshing` to `true`, reloads, and sets it back to `false` in a `finally`. `refreshing` is **required and controlled**: if the handler never sets it to `true`, the component pushes the unchanged `false` back to the native spinner and it disappears at once. Returning a promise from `onRefresh` does not keep the spinner alive — only the prop does. Pull-to-refresh works only on a vertical `ScrollView`. The native pieces differ: on iOS the refresh control lives inside the scroll view, on Android the scroll view is wrapped in a swipe-refresh layout, and styling splits by platform: `colors`, `progressBackgroundColor`, `size` and `enabled` are Android-only; `tintColor`, `title` and `titleColor` are iOS-only.
code
tsx · 42 linesimport {useCallback, useState} from 'react';
import {RefreshControl, ScrollView, StyleSheet, Text} from 'react-native';
type Settings = {pushEnabled: boolean; weeklyDigest: boolean};
export function SettingsScreen({
initial,
load,
}: {
initial: Settings;
load: () => Promise<Settings>;
}) {
const [settings, setSettings] = useState(initial);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true); // controlled: without this the spinner stops at once
try {
setSettings(await load());
} finally {
setRefreshing(false); // also runs when load() rejects
}
}, [load]);
return (
<ScrollView
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#2f6fed']}
tintColor="#2f6fed"
/>
}>
<Text>Push notifications: {settings.pushEnabled ? 'on' : 'off'}</Text>
<Text>Weekly digest: {settings.weeklyDigest ? 'on' : 'off'}</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({content: {padding: 16, gap: 12}});go deeper
Recall the wiring: a RefreshControl element in the refreshControl prop, with refreshing and onRefresh, and refreshing flipped to true and back to false by your handler.
Explain the controlled-prop mechanics: the native spinner is reset to whatever refreshing says, so a missing setRefreshing(true) makes it vanish and a missing false makes it stick. Name the platform-only props.
Harden the handler: finally for errors, a guard against overlapping reloads, and per-platform colours. Know that Android wraps the scroll view and moves outer layout styles onto the wrapper.
Decide where refresh belongs in the product: pull-to-refresh as a convenience on screens with server state, backed by automatic refetching, rather than the only way users can see fresh data.
## The basic wiring Pull-to-refresh in React Native is a separate component, `RefreshControl`, that you hand to a `ScrollView` through its `refreshControl` prop. It is not a child in your JSX; it is an element passed as a prop. When the scroll view is at the top and the user swipes down, the native control starts its spinner and calls `onRefresh`. Two props matter most: - **`refreshing`** (required, boolean) — whether the control should show an active refresh; - **`onRefresh`** — the function called when the user triggers a refresh. A typical settings screen that reloads the user's preferences from the server: 1. keeps `refreshing` in state, starting at `false`; 2. in `onRefresh`, sets it to `true`, awaits the reload, and sets it back to `false` in a `finally` block so an error still stops the spinner; 3. renders the rows from the reloaded data. ## Why refreshing must be controlled state The docs call `refreshing` a **controlled prop**, and the implementation shows why. When the native control starts a refresh, the component records that the native side is refreshing and forces an update. After that update, if the `refreshing` prop you passed is still `false` and differs from what native is showing, it sends a command to the native view to set refreshing back to the prop's value. So: - if `onRefresh` never sets `refreshing` to `true`, the spinner appears and vanishes almost immediately; - if you never set it back to `false`, the spinner stays forever; - setting `refreshing` to `true` from code (for example on first load) shows the spinner without a pull; - `onRefresh` may be `async`, but the returned promise is ignored — the spinner follows the prop, not the promise. ## Platform differences The same JavaScript component maps to different native pieces: | Aspect | iOS | Android | |---|---|---| | Native structure | the refresh control is a child of the scroll view | the scroll view is wrapped in a swipe-refresh layout | | Indicator colour | `tintColor` | `colors` (an array, at least one colour) | | Title text | `title`, `titleColor` | not available | | Indicator background | not available | `progressBackgroundColor` | | Indicator size | not available | `size`: `'default'` or `'large'` | | Disable pulling | not available | `enabled` (default `true`) | | Offset from the top | `progressViewOffset` | `progressViewOffset` | Setting an iOS-only prop on Android, or the reverse, is harmless — the component strips the other platform's props before rendering the native view — but it also does nothing, which is why a single `tintColor` leaves Android with its default colours. Because Android wraps the scroll view, React Native moves the outer layout part of the `ScrollView`'s `style` (margins, size, flex, position) onto that wrapper, and it turns `nestedScrollEnabled` on for the wrapped scroll view by default so the scroll view can handle touches before the refresh layout does. ## Limits worth knowing - **Vertical only.** The docs state that `refreshControl` only works when `horizontal` is `false`. - **Top of content only.** The pull is recognised when the scroll view is at the top; a pull in the middle of the page just scrolls. - **Overlapping reloads.** Guard the handler if a second trigger, such as a retry button, can start the same reload while one is in flight. ## Common mistakes - Setting `refreshing` to `true` only inside a later callback, so the spinner flickers off before it comes back. - Forgetting the `finally`, leaving the spinner stuck after an error. - Styling the spinner with `tintColor` only and shipping Android with the default colours. - Expecting pull-to-refresh on a horizontal carousel.
- How do you show the refresh spinner on first load, without the user pulling?Set `refreshing` to `true` from code while the first load runs and back to `false` when it ends. Because the prop is controlled, the component tells the native control to start or stop refreshing whenever the prop changes, so no gesture is needed.
- Why does the spinner colour change on iOS but not on Android when only tintColor is set?`tintColor` is an iOS-only prop. Android's indicator takes its colours from `colors`, an array of at least one colour, and its circle background from `progressBackgroundColor`. Set both platforms' props to get a consistent look.
- Can a horizontal ScrollView carousel use RefreshControl?No. The `refreshControl` prop only works for vertical scroll views; `horizontal` must be `false`. A horizontal carousel that needs reloading gets its refresh from the vertical screen around it or from an explicit control.
saying these in an interview costs you the question
- RefreshControl manages its own refreshing state, so the prop is optional.
- Returning a promise from onRefresh keeps the spinner until it resolves.
- tintColor sets the refresh indicator colour on both platforms.
- RefreshControl works on horizontal ScrollViews as well.
- Pull-to-refresh can be triggered from anywhere in the content, not only at the top.