Dart Tutorial

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 #

PropertyMeaning
connectionStatewaiting while running, done when finished
hasData / dataThe result, when it succeeded
hasError / errorThe 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.

Practise in the playground Updated by Santosh Adhikari