skip to content

How do you add pull-to-refresh to a React Native ScrollView with RefreshControl, and why must refreshing be state you set?

level: middleimportance: should knowfreq 55%

answer

  1. refreshControl prop takes an element
  2. refreshing is required and controlled
  3. true on start, false in finally
  4. vertical ScrollView only
  5. colors on Android, tintColor on iOS

basics

~20 s

Pass 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 s

You 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 lines
tsx
import {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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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.