FutureBuilder & StreamBuilder: best practices in Flutter with Dart

FutureBuilder & StreamBuilder: best practices in Flutter with Dart

If you develop Flutter apps, you've surely come across the famous FutureBuilder and StreamBuilder. They're great for displaying asynchronous data without headaches, but they can quickly become a source of frustration if used incorrectly.

In this article, we'll go over best practices to avoid crashes and strange bugs. Spoiler: you'll see that with Dart records (introduced in Dart 3), the code becomes even simpler!

What are FutureBuilder and StreamBuilder ?

FutureBuilder

A FutureBuilder allows you to run an asynchronous operation — via a Future — and automatically rebuild the UI when the result is available. For example, if you retrieve a username from an API :

FutureBuilder<String>(
  future: getUserName(), // getUserName() -> Future<String>
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return CircularProgressIndicator();
    }
    if (snapshot.hasError) {
      return Text('Erreur : ${snapshot.error}');
    }
    return Text('Nom : ${snapshot.data}');
  },
)

The principle is simple: you provide your future, and the widget updates when it receives the response.

StreamBuilder

For data that evolves continuously, the StreamBuilder is your best friend. Imagine a counter that increments every second, or a series of messages that arrive over time.

StreamBuilder<int>(
  stream: counterStream(), // counterStream() -> Stream<int>
  builder: (context, snapshot) {
    // L'état active signifie qu'on a commencé à recevoir des données du Stream.
    if (snapshot.connectionState == ConnectionState.active) {
      return Text('Compteur : ${snapshot.data}');
    } else if (snapshot.connectionState == ConnectionState.waiting) {
      // waiting indique qu'on attend encore les premiers events
      return CircularProgressIndicator();
    } else if (snapshot.hasError) {
      return Text('Erreur : ${snapshot.error}');
    } else if (snapshot.connectionState == ConnectionState.done) {
      // quand le flux est terminé
      return Text('Le stream est terminé. Dernière valeur : ${snapshot.data}');
    } else {
      return SizedBox.shrink();
    }
  },
)

Each new event in the Stream will rebuild your widget. Magic. Note here that we distinguish several states :

  • waiting : the stream has been created but hasn't emitted any data yet,
  • active : data is actually being received,
  • done : the stream is closed.

Type inference with Records

There's a small revolution in Dart 3: records! A record is a mini-object that can aggregate several values of varying types, a bit like a Tuple in other languages. Thanks to this, we avoid dealing with List<dynamic> everywhere and gain clarity.

Why is it useful ?

Let's take a concrete example. Say you want to fetch, in a single request, the username and the age of a user. Instead of returning a list (which can be ambiguous: ["Martin", 42]… uh, which order is that again?), Dart 3 allows you to return a record directly. Example: (String, int).

Code examples

Before (with List<dynamic>)

// Simule une fonction qui renvoie deux infos : username et age
Future<List<dynamic>> fetchData() async {
  await Future.delayed(Duration(seconds: 1));
  return ["Martin", 42];
}

FutureBuilder<List<dynamic>>(
  future: fetchData(),
  builder: (context, snapshot) {
    if (snapshot.hasData) {
      final data = snapshot.data!;
      final username = data[0] as String;
      final age = data[1] as int;
      return Text('$username, $age');
    }
    return CircularProgressIndicator();
  },
)
  • Problem: we have to cast (as String, as int), we handle a List<dynamic>… In short, it's not great.

After (with a Record)

// Même fonction, mais on renvoie un record plus explicite
Future<(String, int)> fetchData() async {
  await Future.delayed(Duration(seconds: 1));
  return ("Martin", 42);
}

FutureBuilder<(String, int)>(
  future: fetchData(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return CircularProgressIndicator();
    }
    if (snapshot.hasError) {
      return Text('Erreur : ${snapshot.error}');
    }

    if (snapshot.hasData) {
      final (username, age) = snapshot.data!;
      return Text('$username, $age');
    }
    return SizedBox.shrink(); // cas par défaut
  },
)
  • Here, no need to cast: Dart knows we have (String, int).
  • The code is more readable and we drastically reduce the risk of errors.

Warning: it's better to avoid calling fetchData() directly in the FutureBuilder if you don't want to relaunch it in a loop. We'll talk about this a bit later.

Common mistakes to avoid

Overusing FutureBuilder and StreamBuilder

The big classic mistake: doing a future: fetchData() or stream: getStream() in the build() method. Result? On each rebuild, you relaunch your request (and you cry). The solution? Instantiate your Future or your Stream only once.

Bad usage

FutureBuilder(
  future: fetchData(),
  builder: (context, snapshot) {
    // ...
  },
)

Better approach

late final Future<(String, int)> future;

@override
void initState() {
  super.initState();
  future = fetchData(); // un seul appel
}

@override
Widget build(BuildContext context) {
  return FutureBuilder<(String, int)>(
    future: future,
    builder: (context, snapshot) {
      // ...
    },
  );
}

This prevents surprises and unnecessary calls.

Performance tip: if your result doesn't change often, also consider implementing a caching system or checking if your data is already loaded before relaunching the request. This will prevent overloading the network and re-rendering too often.

Error handling

In case of an error (snapshot.hasError), you can display a dedicated message, or even offer a "Retry" button to relaunch the request. Simplified example :

if (snapshot.hasError) {
  return Column(
    children: [
      Text('Oups ! Erreur : \\${snapshot.error}'),
      ElevatedButton(
        onPressed: () {
          setState(() {
            // On relance le Future ou on appelle un autre mécanisme
            // si on l'a stocké dans future = fetchData() etc.
            future = fetchData();
          });
        },
        child: Text('Réessayer'),
      ),
    ],
  );
}

This way, the user isn't blocked and can retry the action.

Concrete example of a Stream

To go further, let's take an instant messaging scenario. We could imagine a Stream<String> that emits new messages as they arrive :

StreamBuilder<String>(
  stream: chatStream(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return Text('Chargement du chat...');
    } else if (snapshot.hasError) {
      return Text('Erreur : ${snapshot.error}');
    } else if (snapshot.hasData) {
      final newMessage = snapshot.data!;
      return Text('Nouveau message : $newMessage');
    } else {
      return Text('Aucun message');
    }
  },
)

Here, each new message "pushed" into the chatStream() triggers a rebuild. Ideal for a real-time chat or any continuous event stream.

Conclusion

There you go, you have an overview of best practices around FutureBuilder and StreamBuilder. With Dart 3, records make the code cleaner, more robust, and easier to maintain. Just remember to :

  1. Don't trigger your futures/streams in the build().
  2. Properly handle all states (loading, error, done, etc.).
  3. Try records to reduce the use of List<dynamic>.
  4. Consider performance (don't relaunch unnecessarily, think about caching)
  5. Provide a way to handle or retry in case of an error.

Honestly, you'll see that it makes the code more pleasant and less prone to crashes. To go further, take a look at the official Flutter documentation and Dart (notably the records). You will also find other examples and use cases there.

And you, how do you handle your asynchronous calls ? Share your feedback and tips in the comments, I'm curious to read them!

Tags

  • flutter

  • iOS

  • android

  • dart

  • best-practices

This article was posted on

Comments

Loading...

FutureBuilder & StreamBuilder: best practices in Flutter with Dart | DEMILY Clément