Flutter: Asynchrone Listen mit FutureBuilder
Zuletzt aktualisiert:
7 Min. Lesezeit

FutureBuilder verbindet das Ergebnis eines einmaligen asynchronen Vorgangs (API-Anfrage, Datenbankabfrage, Datei lesen) mit dem Widget-Baum. Die Methode build läuft synchron, und darin können Sie kein await verwenden; FutureBuilder abonniert das übergebene Future und ruft builder für jeden der Zustände Warten, Fehler und Ergebnis erneut auf. Wann immer ein Screen mit dem Trio „lädt, Fehler, Daten“ öffnen soll, ist dies das erste Werkzeug, zu dem Sie greifen. Das eigentliche Thema dieses Beitrags ist jedoch der häufigste Fehler: das Future in build zu erzeugen.
Live-Demo
Sie können dieses Widget im interaktiven Beispiel unten ausprobieren:
💡 Falls das Beispiel oben nicht lädt, klicken Sie auf DartPad, um es in einem neuen Tab auszuführen.
Grundlegende Verwendung
class GreetingPage extends StatefulWidget {
const GreetingPage({super.key});
@override
State<GreetingPage> createState() => _GreetingPageState();
}
class _GreetingPageState extends State<GreetingPage> {
// Das Future wird einmal erzeugt; jeder build-Aufruf nutzt dasselbe Objekt
late final Future<String> _greeting = fetchGreeting();
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _greeting,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Fehler: ${snapshot.error}');
}
return Text('Daten: ${snapshot.data}');
},
);
}
}
Future<String> fetchGreeting() async {
await Future.delayed(const Duration(seconds: 2));
return 'Hallo Flutter!';
}Der snapshot, der bei builder ankommt, ist eine Momentaufnahme des Vorgangs: in welcher Phase er ist, ob Daten da sind, ob ein Fehler vorliegt. Wenn async/await und das Konzept Future noch nicht sitzen, empfehle ich zuerst den Beitrag zu asynchronen Abläufen und Fehlerbehandlung in Dart; FutureBuilder baut auf diesem Wissen auf.
Wichtige Eigenschaften
| Eigenschaft | Beschreibung |
|---|---|
future |
Das Future, auf das gewartet wird; bei null ist der Zustand none |
builder |
Funktion, die bei jedem Zustandswechsel aufgerufen wird und ein Widget liefert |
initialData |
Wert, der als snapshot.data angezeigt wird, bis das Future abgeschlossen ist |
Den Snapshot in der richtigen Reihenfolge lesen
AsyncSnapshot trägt drei Dinge: connectionState, data und error. Bei einem Future können Sie diese Zustände sehen (active wird nur beim StreamBuilder verwendet):
| Zustand | Bedeutung |
|---|---|
none |
Das Feld future ist null, es wird auf nichts gewartet |
waiting |
Das Future läuft, noch kein Ergebnis |
done |
Das Future ist fertig; entweder data oder error ist gefüllt |
Die Reihenfolge der Prüfungen ist wichtig. Erst waiting, dann hasError, zuletzt die Daten:
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Fehler: ${snapshot.error}'));
}
final items = snapshot.data ?? const [];
if (items.isEmpty) {
return const Center(child: Text('Keine Einträge'));
}
return ListView.builder(/* ... */);
}hasData sagt nur, dass die Daten nicht null sind. Warten Sie auf ein Future<void> oder auf ein Future, das null liefern kann, wird hasData nie true; erkennen Sie den Abschluss dann über connectionState == ConnectionState.done.
FutureBuilder mit ListView
Der häufigste Einsatz ist, eine Liste von einer API mit ListView.builder anzuzeigen. FutureBuilder umschließt den gesamten Rumpf; die Liste entsteht erst, wenn die Daten da sind:
class MyListPage extends StatefulWidget {
const MyListPage({super.key});
@override
State<MyListPage> createState() => _MyListPageState();
}
class _MyListPageState extends State<MyListPage> {
late final Future<List<String>> _items = fetchItems();
Future<List<String>> fetchItems() async {
await Future.delayed(const Duration(seconds: 2)); // simulierter API-Aufruf
return ['Flutter', 'Dart', 'Firebase', 'Android', 'iOS'];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Liste')),
body: FutureBuilder<List<String>>(
future: _items,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Fehler: ${snapshot.error}'));
}
final items = snapshot.data ?? const [];
if (items.isEmpty) {
return const Center(child: Text('Keine Daten gefunden'));
}
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(items[index]),
);
},
);
},
),
);
}
}Dasselbe Muster verwenden Sie mit GridView.builder; nur das zurückgegebene Widget ändert sich. Die Rastereinstellungen finden Sie im GridView-Beitrag.
Aktualisieren: das Future per setState neu zuweisen
Bekommt das Feld future ein anderes Objekt, lässt FutureBuilder das alte Abonnement fallen und wartet auf das neue. Für einen Aktualisieren-Button oder Pull-to-Refresh müssen Sie nur ein neues Future erzeugen und es per setState dem Feld zuweisen:
class _MyPageState extends State<MyPage> {
late Future<List<String>> _futureData;
@override
void initState() {
super.initState();
_futureData = fetchData();
}
void _reload() {
setState(() {
_futureData = fetchData(); // neues Future, FutureBuilder wartet von vorn
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Daten'),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _reload),
],
),
body: FutureBuilder<List<String>>(
future: _futureData,
builder: (context, snapshot) {
// ...
},
),
);
}
}Ein kleines, aber nützliches Detail: Wenn sich future ändert, wechselt der Snapshot in den Zustand waiting, das bisherige data wird aber nicht gelöscht. Wollen Sie also „während des Aktualisierens die alte Liste weiter anzeigen“, kombinieren Sie die waiting-Prüfung mit !snapshot.hasData; im Szenario unten machen wir genau das.
Wann verwenden – und wann nicht?
Wählen Sie FutureBuilder für Daten, die ein Screen oder ein Abschnitt beim Öffnen einmal lädt und danach nur anzeigt: Nutzerprofil, Produktdetails, Einstellungswerte. Dürfen die Daten beim Schließen des Screens vergessen werden und müssen sie nicht mit anderen Screens geteilt werden, ist das genau der richtige Ort.
In diesen Fällen ist ein anderes Werkzeug richtiger:
- Ändern sich die Daten mit der Zeit (Chatnachrichten, Live-Preise, Firestore-Abfrage), reicht ein einmaliges
Futurenicht; verwenden Sie StreamBuilder. - Werden dieselben Daten auf mehreren Screens gebraucht, oder fügen Sie der Liste Einträge hinzu und entfernen welche, um sie erneut anzuzeigen, lässt sich dieser Zustand nicht in einem
FutureBuilderverwalten. Verschieben Sie die Daten mit Provider in einenChangeNotifieroder in einenFutureProvidervon Riverpod;AsyncValueliefert Ihnen dasselbe Trio „lädt, Fehler, Daten“ unabhängig vom Screen. - Für Vorgänge, die per Button-Tap starten (Speichern, Senden), ist
FutureBuilderungeeignet; einasync-onPressedmit einem persetStateverwalteten_isSaving-Flag ist schlichter. - Die Daten in
initStateperthenzu holen undsetStateaufzurufen ist ebenfalls ein gangbarer Weg; dann liegen aber diemounted-Prüfung und die manuelle Verwaltung der drei Zustände bei Ihnen. Bei kleinen Screens nimmtFutureBuilderIhnen diese Last ab.
Häufige Fehler
1. Das Future in build erzeugen
Der Klassiker sieht so aus:
// FALSCH
FutureBuilder<List<User>>(
future: fetchUsers(), // bei jedem build eine neue Anfrage
builder: (context, snapshot) { /* ... */ },
)Symptom: Bei jedem setState, beim Öffnen der Tastatur, beim Drehen des Geräts oder beim Neuaufbau des übergeordneten Widgets verschwindet die Liste und der Spinner kommt zurück; im Netzwerk-Tab taucht dieselbe Anfrage immer wieder auf. Die Ursache ist simpel: Jedes Mal, wenn build läuft, liefert fetchUsers() ein neues Future, und FutureBuilder sagt „das Future hat sich geändert“ und wartet von vorn. Die Lösung: das Future einmal erzeugen und aufbewahren:
// RICHTIG
class _UsersPageState extends State<UsersPage> {
late final Future<List<User>> _users = fetchUsers();
// oder in initState: _users = fetchUsers();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<User>>(
future: _users,
builder: (context, snapshot) { /* ... */ },
);
}
}In einem StatelessWidget gibt es diesen Aufbewahrungsort nicht; machen Sie das Widget mit dem FutureBuilder zu einem StatefulWidget.
2. data! verwenden, ohne den Fehlerzustand zu prüfen
Symptom: Wirft das Future einen Fehler, gibt es einen roten Bildschirm mit dieser Meldung:
Null check operator used on a null valueFutureBuilder fängt den Fehler und legt ihn in snapshot.error; data bleibt null. Überspringen Sie die hasError-Prüfung und schreiben snapshot.data!, ist es Ihr Code, der abstürzt. Prüfen Sie immer zuerst hasError und geben Sie dem Nutzer einen Weg zum erneuten Versuch. Und noch etwas: Fehler, die innerhalb der builder-Funktion selbst geworfen werden, landen nicht in hasError, sondern stürzen direkt ab.
3. Bei Future auf hasData warten
Symptom: Der Vorgang ist fertig, aber der Screen bleibt beim Spinner. Ein Future<void> trägt nie Daten, hasData ist immer false. Erkennen Sie den Abschluss so:
if (snapshot.connectionState == ConnectionState.done) {
return const Text('Abgeschlossen');
}
return const CircularProgressIndicator();4. Ein von Widget-Parametern abhängiges Future nie erneuern
Haben Sie late final Future _detail = fetchDetail(widget.id); geschrieben, ist dieses Future für die gesamte Lebensdauer des Widgets fest. Ändert sich widget.id, während derselbe State weiterlebt (etwa wenn dasselbe Widget in einer PageView mit anderen IDs wiederverwendet wird), bleiben die alten Daten auf dem Bildschirm. Weisen Sie das Future in diesem Fall in didUpdateWidget mit der Prüfung oldWidget.id != widget.id neu zu.
Mini-Szenario: Nutzerliste von einer API
Stellen Sie sich einen Verwaltungs-Screen vor: Beim Öffnen kommen die Nutzer von der API, zieht der Nutzer nach unten, wird die Liste aktualisiert, und schlägt die Anfrage fehl, erscheint ein Button „Erneut versuchen“. Während der Aktualisierung bleibt die alte Liste sichtbar; der Spinner erscheint nur beim ersten Öffnen.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class User {
const User({required this.id, required this.name, required this.email});
final int id;
final String name;
final String email;
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
);
}
Future<List<User>> fetchUsers() async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/users'),
);
if (response.statusCode != 200) {
throw Exception('Nutzer konnten nicht geladen werden (${response.statusCode})');
}
final data = jsonDecode(response.body) as List<dynamic>;
return data.map((e) => User.fromJson(e as Map<String, dynamic>)).toList();
}
class UsersPage extends StatefulWidget {
const UsersPage({super.key});
@override
State<UsersPage> createState() => _UsersPageState();
}
class _UsersPageState extends State<UsersPage> {
late Future<List<User>> _usersFuture;
@override
void initState() {
super.initState();
_usersFuture = fetchUsers();
}
Future<void> _refresh() async {
final next = fetchUsers();
setState(() => _usersFuture = next);
try {
await next; // RefreshIndicator dreht sich, bis dieses Future fertig ist
} catch (_) {
// Der Fehler wird vom FutureBuilder angezeigt
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Nutzer')),
body: FutureBuilder<List<User>>(
future: _usersFuture,
builder: (context, snapshot) {
final isFirstLoad =
snapshot.connectionState == ConnectionState.waiting &&
!snapshot.hasData;
if (isFirstLoad) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Fehler: ${snapshot.error}'),
const SizedBox(height: 12),
FilledButton(
onPressed: _refresh,
child: const Text('Erneut versuchen'),
),
],
),
);
}
final users = snapshot.data ?? const [];
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: users.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
leading: CircleAvatar(child: Text(user.name[0])),
title: Text(user.name),
subtitle: Text(user.email),
);
},
),
);
},
),
);
}
}Hier stecken vier Entscheidungen. Das Future wird einmal in initState erzeugt; egal wie oft build läuft, die Anfrage wiederholt sich nicht. _refresh erzeugt ein neues Future, weist es per setState zu und wartet auf dasselbe Future, damit sich der Ring des RefreshIndicator bis zum Ende dreht; der Fehler wird per catch geschluckt, weil ihn anzuzeigen die Aufgabe des FutureBuilder ist. Dank der isFirstLoad-Prüfung bleibt die alte Liste während der Aktualisierung sichtbar. AlwaysScrollableScrollPhysics erlaubt das Herunterziehen auch bei einer kurzen Liste; ohne sie funktioniert die Geste bei einer Liste mit zwei Nutzern nicht. Vergessen Sie nicht, das Paket http in pubspec.yaml einzutragen. Die Netzwerkseite im Detail, also POST-, PUT- und DELETE-Anfragen, die Prüfung von Statuscodes und Timeouts, behandelt der Beitrag über HTTP-Anfragen und REST-APIs.
Häufig gestellte Fragen
Warum lädt FutureBuilder ständig neu?
Weil Sie dem Feld future eine in build aufgerufene Funktion (future: fetchData()) übergeben. Jeder build erzeugt ein neues Future, und FutureBuilder wartet von vorn. Erzeugen Sie das Future einmal in initState oder in einem late final-Feld und übergeben Sie in build dieses Feld.
Wie aktualisiere ich die Daten mit FutureBuilder?
Erzeugen Sie ein neues Future und weisen Sie es innerhalb von setState dem Feld zu. Sieht FutureBuilder das neue Objekt, lässt er das alte Abonnement fallen und wartet auf das neue; Pull-to-Refresh mit RefreshIndicator funktioniert auf demselben Weg.
Was ist der Unterschied zwischen FutureBuilder und StreamBuilder?
FutureBuilder wartet auf ein einziges Ergebnis und ändert sich nach done nicht mehr; StreamBuilder hört einen Datenstrom ab und zeichnet bei jedem neuen Wert neu. Für eine einmalige API-Anfrage den ersten, für sich ständig ändernde Quellen wie Chats oder Live-Daten den zweiten.
Warum bleibt snapshot.hasData false?
hasData wird nur true, wenn data nicht null ist. Bei einem Future<void> oder einem Future, das null liefert, wird es nie true; prüfen Sie den Abschluss über connectionState == ConnectionState.done.
Verwandte Artikel
Flutter: HTTP-Anfragen und REST-APIs
GET, POST, PUT und DELETE mit dem http-Paket in Flutter, JSON in Modellklassen, Statuscodes, Timeouts sowie Lade- und Fehlerzustände mit FutureBuilder.
Flutter: Lokale Datenspeicherung mit SharedPreferences
Theme und Einstellungen mit shared_preferences speichern: SharedPreferencesAsync, WithCache und alte API im Vergleich, Migration und was nicht hineingehört.
Flutter: Echtzeit-Datenströme mit StreamBuilder
StreamBuilder in Flutter: connectionState bei Streams, initialData, Broadcast vs. Single-Subscription, StreamController schließen, listen() im Vergleich.