Every screen that loads data must handle loading and failure gracefully.
`FutureBuilder<T>` rebuilds when a future completes, and `StreamBuilder<T>` whenever a stream emits. Both call your builder(context, snapshot) with an `AsyncSnapshot`:
snapshot.connectionState—none,waiting,active(streams) ordone.snapshot.hasData/snapshot.data.snapshot.hasError/snapshot.error.
A typical builder returns a progress indicator while waiting, an error view on error, and the content otherwise. Dart 3 patterns make this tidy: switch (snapshot) { AsyncSnapshot(hasError: true) => ..., AsyncSnapshot(:final data?) => ..., _ => spinner }.
The most common bug: creating the future inside `build`:
FutureBuilder(future: api.fetchUser(), ...) // refetches on every rebuild!Any rebuild — a theme change, keyboard opening, parent setState — starts a new request and flashes the spinner. Instead, create the future once in initState (or in a view model) and store it in a field. To refresh, assign a new future inside setState.
StreamBuilder subscribes and unsubscribes automatically as the widget enters and leaves the tree. Pass initialData to avoid a flicker when you already have a value.
For more complex apps, keep async loading in a state-management layer and expose simple states (loading, data, error) to widgets; FutureBuilder is ideal for small, local cases.
Example
import 'package:flutter/material.dart';
Future<List<String>> fetchCities() async {
await Future<void>.delayed(const Duration(seconds: 1));
return ['Oslo', 'Lima', 'Pune'];
}
class CitiesScreen extends StatefulWidget {
const CitiesScreen({super.key});
@override
State<CitiesScreen> createState() => _CitiesScreenState();
}
class _CitiesScreenState extends State<CitiesScreen> {
late Future<List<String>> _cities = fetchCities(); // created once
@override
Widget build(BuildContext context) => FutureBuilder<List<String>>(
future: _cities,
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(hasError: true) => TextButton(
onPressed: () => setState(() => _cities = fetchCities()),
child: Text('Error: ${snapshot.error}. Retry')),
AsyncSnapshot(:final data?) => ListView(children: [for (final c in data) Text(c)]),
_ => const Center(child: CircularProgressIndicator()),
},
);
}Remember
- Handle waiting, error and data states from AsyncSnapshot
- Create the future once (initState or a field), never in
build - Refresh by assigning a new future in
setState
Watch out for
- Calling the fetch function directly in the
future:argument, re-running it on every rebuild.