FutureBuilder & StreamBuilder: buenas prácticas en Flutter con Dart

Sommaire
Si desarrollas apps Flutter, seguramente te has topado con los famosos FutureBuilder y StreamBuilder. Son geniales para mostrar datos asíncronos sin complicarte, pero pueden convertirse rápidamente en fuente de frustración si se usan mal.
En este artículo, repasaremos las buenas prácticas para evitar crashes y bugs extraños. Spoiler: verás que con los records de Dart (introducidos en Dart 3), ¡simplificamos aún más el código!
¿Qué son FutureBuilder y StreamBuilder ?
FutureBuilder
Un FutureBuilder te permite lanzar un proceso asíncrono —vía un Future— y reconstruir automáticamente la UI cuando el resultado está disponible. Por ejemplo, si obtienes un nombre de usuario desde una 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}');
},
)
El principio es simple: proporcionas tu future, y el widget se actualiza cuando recibe la respuesta.
StreamBuilder
Para datos que evolucionan de forma continua, el StreamBuilder es tu mejor amigo. Imagina un contador que se incrementa cada segundo, o una serie de mensajes que llegan poco a poco.
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();
}
},
)
Cada nuevo evento en el Stream vuelve a construir tu widget. Mágico. Aquí distinguimos varios estados:
waiting: el stream está creado pero aún no ha emitido datos,active: efectivamente estamos recibiendo datos,done: el flujo está cerrado.
Inferencia de tipos con Record
Hay una pequeña revolución en Dart 3: ¡los records! Un record es un mini-objeto que puede agregar varios valores de tipos variados, un poco como una Tuple en otros lenguajes. Gracias a esto, evitamos andar con List<dynamic> por todas partes y ganamos claridad.
¿Por qué es útil?
Tomemos un ejemplo concreto. Supongamos que quieres recuperar, en una sola petición, el alias y la edad de un usuario. En lugar de devolver una lista (lo cual puede ser ambiguo: ["Martin", 42]… eh, ¿en qué orden era?), Dart 3 te permite devolver directamente un record. Ejemplo: (String, int).
Ejemplos de código
Antes (con 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();
},
)
- Problema: hay que castear (
as String,as int), se maneja unList<dynamic>… En resumen, no es lo ideal.
Después (con 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
},
)
- Aquí ya no hace falta castear: Dart sabe que tenemos
(String, int). - El código es más legible y reducimos drásticamente el riesgo de error.
Atención: es mejor evitar llamar a
fetchData()directamente en elFutureBuildersi no quieres relanzarlo en bucle. Hablaremos de ello un poco más abajo.
Errores frecuentes a evitar
Abuso de FutureBuilder y StreamBuilder
El gran error clásico: poner future: fetchData() o stream: getStream() dentro del método build(). ¿Resultado? A cada rebuild relanzas tu petición (y lloras). ¿La solución? Instancia tu Future o tu Stream una sola vez.
❌ Uso incorrecto
FutureBuilder(
future: fetchData(),
builder: (context, snapshot) {
// ...
},
)
✅ Mejor enfoque
late final Future<(String, int)> future;
@override
void initState() {
super.initState();
future = fetchData(); // un solo llamado
}
@override
Widget build(BuildContext context) {
return FutureBuilder<(String, int)>(
future: future,
builder: (context, snapshot) {
// ...
},
);
}
Eso te evita sorpresas y llamadas innecesarias.
Consejo de rendimiento : si tu resultado no cambia a menudo, piensa también en implementar un sistema de cache o comprobar si tus datos ya están cargados antes de relanzar la petición. Eso te evitará sobrecargar la red y volver a renderizar con demasiada frecuencia.
Gestión de errores
En caso de error (snapshot.hasError), puedes mostrar un mensaje dedicado, incluso ofrecer un botón "Reintentar" para relanzar la petición. Ejemplo simplificado:
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'),
),
],
);
}
Así, el usuario no queda bloqueado y puede repetir la acción.
Ejemplo de uso concreto de un Stream
Para ir más lejos, tomemos un escenario de mensajería instantánea. Podríamos imaginar un Stream<String> que emite nuevos mensajes a medida que llegan:
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');
}
},
)
Aquí, cada nuevo mensaje "push" en chatStream() dispara un rebuild. Ideal para un chat en tiempo real o cualquier flujo continuo de eventos.
Conclusión
Y ya está, tienes una visión de las buenas prácticas alrededor de FutureBuilder y StreamBuilder. Con Dart 3, los records hacen el código más limpio, más robusto y más fácil de mantener. Recuerda simplemente:
- No desencadenar tus futures/streams dentro de
build(). - Manejar bien todos los estados (loading, error, done, etc.).
- Probar los records para reducir el uso de
List<dynamic>. - Tener en cuenta el rendimiento (no relanzar innecesariamente, pensar en el cache)
- Ofrecer una forma de manejar o relanzar en caso de error.
De verdad, verás que hace el código más agradable y menos propenso a fallos. Para profundizar, echa un vistazo a la documentación oficial de Flutter y a Dart (en particular los records). Allí encontrarás también otros ejemplos y casos de uso.
¿Y tú, cómo gestionas tus llamadas asíncronas? Comparte tus comentarios y consejos en los comentarios, ¡tengo curiosidad por leerlos!
Comentarios
Cargando...