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: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowEvery 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';.
I post short Flutter UI tips and gesture demos on Instagram most days of the week.
On LinkedIn I share longer notes on Flutter UX patterns and the small details that make apps feel polished.
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.

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 nowCustomise 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.
onRefreshmust 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 plainColumnhas no overscroll. - Using context across the async gap. Guard with
if (!mounted) return;beforesetStateafter the await. - Firing the fetch without awaiting.
awaitthe work insideonRefreshso 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:
- Flutter Development Guide 2026 — the full Flutter hub.
- Flutter REST API Integration — refresh fetched data.
- Flutter ListView.builder — the list you refresh.
- Flutter HTTP Requests — the fetch behind the refresh.
- Flutter Scrolling and Slivers — scroll physics in depth.
- Flutter Infinite Scroll Pagination — load more pages as the user scrolls.
- Flutter Handle API Errors — retries, recovery, and user feedback.
Sources: Flutter documentation — RefreshIndicator and the pull-to-refresh cookbook recipe (api.flutter.dev, docs.flutter.dev). Verified against current stable Flutter.