FutureBuilder and StreamBuilder

Flutter · Practical · 12 min

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) or done.
  • 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.

Check yourself

  1. Why is `FutureBuilder(future: http.get(url), ...)` written directly in build a bug?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: api.flutter.dev/flutter/widgets/FutureBuilder-class.html