Shipnative
ShipnativeShipnative
Sign in
GuideSeptember 2026 · 7 min read

React Native Pull to Refresh: RefreshControl Guide (2026)

Pull-to-refresh is four lines of code and two of them are usually wrong. React Native ships RefreshControlin core — no library needed — but it hands you a controlled component and then leaves you responsible for the state machine behind it. That’s where the spinner-that-never-stops comes from, and why the spinner is invisible on exactly one platform. Here’s the version that works on both.

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

PropPlatformNotes
refreshingBothRequired. Your state — RN never sets it for you.
onRefreshBothFired once per pull past the threshold.
tintColoriOSThe spinner colour. Ignored on Android.
title / titleColoriOSOptional label under the spinner.
colorsAndroidArray — Android cycles through them.
progressBackgroundColorAndroidThe circle behind the spinner.
progressViewOffsetBothPush 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

  1. The spinner never stops. The reset isn’t in finally, so a thrown fetch strands it.
  2. The spinner is invisible. You set tintColor and tested on iOS. Android needs colors, and its default is a dark spinner that vanishes on a dark background.
  3. Nothing happens when you pull. The RefreshControl is on a View rather than a scrollable, or you passed onRefresh directly to the list instead of inside a RefreshControl.
  4. 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.
  5. It works in Expo Go but not in a build. Nearly always a nested scroll problem rather than the refresh control — a FlatList inside a ScrollView, where the outer one eats the gesture.
  6. 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.

Frequently Asked Questions

How do I add pull to refresh in React Native?

Pass a RefreshControl element to the refreshControl prop of a FlatList, SectionList, or ScrollView. RefreshControl takes a refreshing boolean and an onRefresh callback. You own the boolean: set it true when the fetch starts and false when it settles. React Native does not manage that state for you.

Why does my refresh spinner never stop?

Almost always because refreshing was never set back to false — usually because the fetch threw and the setter only ran in the success path. Put the reset in a finally block. The second cause is passing a value that is not actually state, such as a ref or a variable that never triggers a re-render, so the component never receives refreshing={false}.

Can I use pull to refresh on a plain View?

No. RefreshControl only works on scrollable containers — ScrollView, FlatList, SectionList, and FlashList. If your screen is a non-scrolling View, wrap the content in a ScrollView with contentContainerStyle={{ flexGrow: 1 }} so it still fills the screen but can accept the gesture.

How do I change the pull to refresh spinner color?

The two platforms use different props, and this is the most common reason the spinner looks invisible. iOS uses tintColor for the spinner and titleColor for the optional text label. Android uses colors, which takes an array, plus progressBackgroundColor for the circle behind it. Set all of them or the spinner will disappear on one platform in dark mode.

Should I use pull to refresh or a refresh button?

Pull to refresh is the expected gesture on a feed or list that changes over time, and it costs nothing to add. It is the wrong pattern for a screen that only ever loads once, for a horizontal list, and for anything where the user would lose in-progress input by refreshing. In those cases an explicit control is clearer.

→

React Native FlatList: Performance Guide

The list this attaches to — keys, memoized rows, and infinite scroll.

Read guide →
→

React Native Skeleton Loaders

What to show on the first load, when a spinner is the wrong answer.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.