Flutter Lesson 44 of 83 3 min read
FutureBuilder in Flutter
Learn FutureBuilder in Flutter: build UI from a Future, handle loading, error and data states, and avoid restarting the future on rebuild.
On this page
A build method must return a widget immediately. It cannot await. FutureBuilder bridges the gap: give it a future, and it rebuilds when the future completes.
The three states #
import 'package:flutter/material.dart';
Future<String> fetchQuote() async {
await Future<void>.delayed(const Duration(seconds: 2)); // pretend network call
return 'Simplicity is the soul of efficiency.';
}
void main() => runApp(const MaterialApp(home: QuotePage()));
class QuotePage extends StatefulWidget {
const QuotePage({super.key});
@override
State<QuotePage> createState() => _QuotePageState();
}
class _QuotePageState extends State<QuotePage> {
late Future<String> _quote;
@override
void initState() {
super.initState();
_quote = fetchQuote(); // start once
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Quote of the day')),
body: Center(
child: FutureBuilder<String>(
future: _quote,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Something went wrong: ${snapshot.error}');
}
return Padding(
padding: const EdgeInsets.all(24),
child: Text(snapshot.data!, style: const TextStyle(fontSize: 22)),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _quote = fetchQuote()), // reload
child: const Icon(Icons.refresh),
),
);
}
}
The builder is called at least twice: once straight away while waiting, and again when the future finishes.
The snapshot #
| Property | Meaning |
|---|---|
connectionState | waiting while running, done when finished |
hasData / data | The result, when it succeeded |
hasError / error | The exception, when it failed |
Check them in this order: waiting, then error, then data.
The mistake everyone makes once #
// Wrong: the request restarts on every rebuild.
FutureBuilder<String>(
future: fetchQuote(),
builder: ...,
)
build runs often: when the keyboard opens, the theme changes, a parent rebuilds. Each time, fetchQuote() is called again, creating a new future, and the spinner returns. The app may hit the server dozens of times.
Create the future once, in initState or in a state holder, and pass the stored future to the builder, as the first example does.
Reloading #
Assign a new future inside setState. FutureBuilder notices the different future and starts again.
setState(() => _quote = fetchQuote());
To keep showing the old data while the new request runs, check snapshot.hasData before the waiting check and show a small progress bar on top.
Lists #
FutureBuilder<List<String>>(
future: _cities,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return const Center(child: Text('Could not load cities'));
}
final cities = snapshot.data!;
if (cities.isEmpty) {
return const Center(child: Text('No cities found'));
}
return ListView.builder(
itemCount: cities.length,
itemBuilder: (context, i) => ListTile(title: Text(cities[i])),
);
},
)
Four outcomes, four widgets: loading, error, empty and data. Handle all of them on every screen that loads something.
With switch #
Pattern matching makes the builder compact.
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(connectionState: ConnectionState.waiting) =>
const Center(child: CircularProgressIndicator()),
AsyncSnapshot(hasError: true) => const Center(child: Text('Failed to load')),
AsyncSnapshot(:final data?) => Text(data),
_ => const SizedBox.shrink(),
},
When FutureBuilder is the right tool #
It is ideal for a one-off load on a single screen. It becomes awkward when:
- Several widgets need the same data.
- You need to refresh, paginate or change the data after loading.
- The request should survive leaving and returning to the screen.
For those, keep the data in a state holder (a ChangeNotifier, a Riverpod FutureProvider or a Cubit) and let widgets watch it. See loading and error states.
Try it yourself #
Write a function that waits two seconds and then either returns a list of five names or throws, chosen at random. Show the result with a FutureBuilder that handles loading, error and data, and add a retry button to the error view.