Flutter Pull to Refresh: RefreshIndicator Tutorial

Coding Liquids blog cover featuring Sagnik Bhattacharya for the Flutter pull to refresh guide, with a list being pulled down to reveal a RefreshIndicator spinner.
Coding Liquids blog cover featuring Sagnik Bhattacharya for the Flutter pull to refresh guide, with a list being pulled down to reveal a RefreshIndicator spinner.

Pull to refresh is one of those features that looks like it must be fiddly and turns out to be a single widget. Flutter ships RefreshIndicator, the Material pull-down spinner, and wiring it up is mostly about one rule: onRefresh must return a Future, and the spinner stays until that Future completes. This tutorial covers the one-line wrap, returning the right Future, the classic short-list gotcha, customising the indicator, and triggering a refresh from code, with paste-ready snippets.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Every snippet below is correct against current stable Flutter. RefreshIndicator is part of the Material library, so no extra package is needed — it comes with import 'package:flutter/material.dart';.

Follow me on Instagram@sagnikteaches

I post short Flutter UI tips and gesture demos on Instagram most days of the week.

Connect on LinkedInSagnik Bhattacharya

On LinkedIn I share longer notes on Flutter UX patterns and the small details that make apps feel polished.

Subscribe on YouTube@codingliquids

RefreshIndicator in one wrap

To add pull to refresh, wrap your scrollable in a RefreshIndicator and pass an onRefresh callback. The widget handles the gesture, the animation, and the spinner; you just supply the work.

RefreshIndicator(
  onRefresh: _refresh,
  child: ListView.builder(
    itemCount: items.length,
    itemBuilder: (_, i) => ListTile(title: Text(items[i])),
  ),
)

The child must be a scrollable widget — a ListView, GridView, or CustomScrollView. When the user drags down past the top, the indicator slides in and runs your callback. The list itself is unchanged; the indicator simply wraps it.

Return a Future from onRefresh

The one rule that matters: onRefresh must return a Future, and the spinner shows until that Future resolves. So await the actual work inside the callback rather than firing it and returning immediately.

Future<void> _refresh() async {
  final fresh = await fetchItems(); // your network call
  if (!mounted) return;             // guard the async gap
  setState(() => items = fresh);
}

Because the function is async, it returns a Future<void> that completes only after the fetch finishes and state is updated — so the spinner stays up for exactly the right time. If you return a future that resolves instantly, the spinner flashes and vanishes before the data lands. The fetch behind it is the same GET you would write anywhere; see the http requests tutorial for that layer.

Make short lists scrollable (AlwaysScrollableScrollPhysics)

The most common complaint is "pull to refresh does nothing." Almost always the list is shorter than the screen, so it cannot scroll, so there is no overscroll gesture for the indicator to catch. The fix is to force the scroll physics to always allow overscroll.

RefreshIndicator(
  onRefresh: _refresh,
  child: ListView.builder(
    physics: const AlwaysScrollableScrollPhysics(),
    itemCount: items.length,
    itemBuilder: (_, i) => ListTile(title: Text(items[i])),
  ),
)

AlwaysScrollableScrollPhysics keeps the drag gesture available even when the content fits on screen, so the pull works regardless of how few items there are. For the deeper mechanics of scroll physics and slivers, see the scrolling and slivers guide.

The Complete Flutter Guide course thumbnail

Build lists that fetch, refresh, and feel native

The Complete Flutter Guide covers pull-to-refresh, pagination, and live data inside real, shipped apps.

Enrol now

Customise the indicator

RefreshIndicator exposes plenty of styling. color sets the spinner, backgroundColor the disc behind it, strokeWidth the line thickness, and displacement how far down it settles while spinning.

RefreshIndicator(
  onRefresh: _refresh,
  color: Colors.deepPurple,
  backgroundColor: Colors.white,
  strokeWidth: 3,
  displacement: 56,
  child: list,
)

For consistent styling across the whole app, set a RefreshIndicatorThemeData on your ThemeData instead of repeating the colours at every call site. That keeps every indicator on brand from one place.

Trigger a refresh from code (RefreshIndicatorState)

Sometimes you want to refresh without a user pull — on first load, or after an action elsewhere. Attach a GlobalKey<RefreshIndicatorState> and call show() on its state; it runs the same onRefresh and animates the spinner exactly like a manual pull.

final _refreshKey = GlobalKey<RefreshIndicatorState>();

// in build:
RefreshIndicator(
  key: _refreshKey,
  onRefresh: _refresh,
  child: list,
);

// trigger from anywhere:
_refreshKey.currentState?.show();

Calling show() from initState (after the first frame) is a tidy way to load data on entry while reusing the exact same refresh path, so there is only ever one place that fetches.

Common mistakes

  • Not returning a Future. onRefresh must return one that completes when the work is done, or the spinner flashes and disappears.
  • Short list won't pull. Add physics: const AlwaysScrollableScrollPhysics() so overscroll works when content fits the screen.
  • Wrapping a non-scrollable child. The child must be a scrollable like ListView; a plain Column has no overscroll.
  • Using context across the async gap. Guard with if (!mounted) return; before setState after the await.
  • Firing the fetch without awaiting. await the work inside onRefresh so the spinner stays until fresh data arrives.

Frequently asked questions

How do I add pull to refresh in Flutter?

Wrap a scrollable such as a ListView in a RefreshIndicator with an onRefresh callback that returns a Future. The spinner appears on pull-down and stays until that Future completes, so fetch your data and update state inside it.

Why does pull to refresh not work on a short list?

A list shorter than the screen does not scroll, so there is no overscroll for the indicator to catch. Set physics: const AlwaysScrollableScrollPhysics() on the list to keep the gesture available no matter the length.

How do I trigger a refresh programmatically?

Give the RefreshIndicator a GlobalKey<RefreshIndicatorState> and call _key.currentState?.show(). It runs the same onRefresh and shows the spinner just like a user pull — handy on first load.

Can I customise the RefreshIndicator colour and size?

Yes. Set color, backgroundColor, strokeWidth, and displacement on the widget, or define a RefreshIndicatorThemeData in your ThemeData for app-wide styling.

Further reads

Keep going with the tutorials that pair with this guide:

Sources: Flutter documentation — RefreshIndicator and the pull-to-refresh cookbook recipe (api.flutter.dev, docs.flutter.dev). Verified against current stable Flutter.