FutureBuilder & StreamBuilder : bonnes pratiques en Flutter avec Dart
Sommaire
Si tu développes des applis Flutter, tu as forcément croisé la route des fameux FutureBuilder
et StreamBuilder
. Ils sont géniaux pour afficher des données asynchrones sans te prendre la tête, mais ils peuvent vite devenir source de frustration si on les utilise mal.
Dans cet article, on va faire le tour des bonnes pratiques pour éviter les crashs et les bugs étranges. Spoiler : tu verras quʼavec les records de Dart (introduits en Dart 3), on simplifie encore plus le code !
Qu'est-ce que FutureBuilder et StreamBuilder ?
FutureBuilder
Un FutureBuilder
te permet de lancer un traitement asynchrone — via un Future
— et de reconstruire automatiquement lʼUI quand le résultat est disponible. Par exemple, si tu récupères un nom dʼutilisateur depuis une 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}');
},
)
Le principe est simple : tu fournis ton future
, et le widget se met à jour quand il reçoit la réponse.
StreamBuilder
Pour des données qui évoluent en continu, le StreamBuilder
est ton meilleur ami. Imagine un compteur qui sʼincrémente toutes les secondes, ou une série de messages qui arrivent au fur et à mesure.
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();
}
},
)
Chaque nouvel événement dans le Stream
va rebuild ton widget. Magique. Note ici qu’on distingue plusieurs états :
waiting
: le stream est créé mais n’a pas encore émis de données,active
: on reçoit effectivement des données,done
: le flux est fermé.
Inférence des types avec Record
Il y a une petite révolution dans Dart 3 : les records ! Un record, cʼest un mini-objet qui peut agréger plusieurs valeurs de types variés, un peu comme un Tuple
dans dʼautres langages. Grâce à ça, on évite de se coltiner des List<dynamic>
à tout-va et on gagne en clarté.
Pourquoi cʼest utile ?
Prenons un exemple concret. Disons que tu veux récupérer, en une seule requête, le pseudo et lʼâge dʼun utilisateur. Au lieu de retourner une liste (ce qui peut être ambigu : ["Martin", 42]
… euh, cʼest dans quel ordre déjà ?), Dart 3 te permet de retourner directement un record. Exemple : (String, int)
.
Exemples de code
Avant (avec 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();
},
)
- Problème : on doit caster (
as String
,as int
), on manipule duList<dynamic>
… Bref, cʼest pas top.
Après (avec un 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
},
)
- Ici, plus besoin de caster : Dart sait quʼon a
(String, int)
. - Le code est plus lisible et on réduit drastiquement le risque dʼerreur.
Attention : il vaut mieux éviter dʼappeler
fetchData()
directement dans leFutureBuilder
si tu ne veux pas le relancer en boucle. On en parle un peu plus bas.
Erreurs fréquentes à éviter
Abus de FutureBuilder et StreamBuilder
La grosse erreur classique : faire un future: fetchData()
ou stream: getStream()
dans la méthode build()
. Résultat ? À chaque rebuild, tu relances ta requête (et tu pleures). La solution ? Instancier ton Future ou ton Stream une seule fois.
❌ Mauvais usage
FutureBuilder(
future: fetchData(),
builder: (context, snapshot) {
// ...
},
)
✅ Meilleure approche
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) {
// ...
},
);
}
Ça tʼévite des surprises et des appels inutiles.
Astuce Performance : si ton résultat ne change pas souvent, pense aussi à mettre un système de cache ou à vérifier si tes données sont déjà chargées avant de relancer la requête. Ça tʼévitera de surcharger le réseau et de re-render trop souvent.
Gestion des erreurs
En cas d’erreur (snapshot.hasError
), tu peux afficher un message dédié, voire proposer un bouton “Réessayer” pour relancer la requête. Exemple simplifié :
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'),
),
],
);
}
Ainsi, l’utilisateur nʼest pas bloqué et peut réitérer l’action.
Exemple dʼusage concret d’un Stream
Pour aller plus loin, prenons un scénario de messagerie instantanée. On pourrait imaginer un Stream<String>
qui émet de nouveaux messages au fur et à mesure :
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');
}
},
)
Ici, chaque nouveau message “pushé” dans le chatStream() déclenche un rebuild. Idéal pour un chat en temps réel ou tout flux continu d’événements.
Conclusion
Et voilà, tu as un aperçu des bonnes pratiques autour de FutureBuilder
et StreamBuilder
. Avec Dart 3, les records rendent le code plus propre, plus robuste et plus facile à maintenir. Souviens-toi simplement de :
- Ne pas déclencher tes futures/streams dans le
build()
. - Bien gérer tous les états (loading, error, done, etc.).
- Tenter les records pour réduire lʼusage de
List<dynamic>
. - Prendre en compte la performance (ne pas relancer inutilement, penser au cache)
- Offrir un moyen de gérer ou relancer en cas d’erreur.
Franchement, tu verras que ça rend le code plus agréable et moins sujet aux plantages. Pour aller plus loin, jette un œil à la documentation officielle de Flutter et de Dart (notamment les records). Tu y trouveras aussi d’autres exemples et cas d’usage.
Et toi, comment tu gères tes appels asynchrones ? Partage tes retours et astuces dans les commentaires, je suis curieux de lire ça !