Futures & Streams in the UI
The bridge from async to widgets
Section titled “The bridge from async to widgets”You cannot await inside build — build must be synchronous and may run many times for reasons that have nothing to do with your data (a parent rebuild, a theme change, a keyboard opening). So Flutter gives you two widgets that subscribe to an async source and rebuild your UI when it emits: FutureBuilder for a one-shot Future, and StreamBuilder for a Stream of values.
Both hand your builder an AsyncSnapshot, which describes the current state of the async source: are we still waiting, do we have data, or did it error?
FutureBuilder
Section titled “FutureBuilder”FutureBuilder<User>( future: _userFuture, // a Future created OUTSIDE build (see the trap, below) builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const CircularProgressIndicator(); // loading } if (snapshot.hasError) { return Text('Failed: ${snapshot.error}'); // error } final user = snapshot.data!; // data is ready return Text(user.name); },)Read the snapshot in a fixed order: connection state → error → data. Handling all three (plus an empty state where relevant) is what separates a robust screen from one that flashes a null error for a moment.
StreamBuilder
Section titled “StreamBuilder”StreamBuilder is the same shape for a Stream — it rebuilds on every emitted value, so it suits live data (a chat feed, a location stream, a Bloc state).
StreamBuilder<int>( stream: _tickerStream, initialData: 0, builder: (context, snapshot) { return Text('Count: ${snapshot.data}'); // rebuilds on each new value },)The create-in-build trap, proven with a real test
Section titled “The create-in-build trap, proven with a real test”This is the single most common FutureBuilder bug: creating the future inside build. Because build can run for many reasons unrelated to the data, a future created there is recreated each time — re-triggering the underlying work and resetting the snapshot to waiting.
import 'dart:async';import 'package:flutter/material.dart';
/// Counts how many times [fetchValue] actually ran — the proof mechanism/// for whether the Future was recreated on every rebuild (the trap) or/// created exactly once (the fix).int fetchCallCount = 0;
Future<int> fetchValue() async { fetchCallCount++; return 42;}
Future<int> delayedFetch() => Future.delayed(const Duration(milliseconds: 10), () => 42);
/// BUGGY — `fetchValue()` is called directly inside `build`, so it is a NEW/// Future object every single time this widget rebuilds.class BuggyFutureWidget extends StatelessWidget { const BuggyFutureWidget({super.key});
@override Widget build(BuildContext context) { return FutureBuilder<int>( future: fetchValue(), // <-- recreated on every build builder: (context, snapshot) => Text('state: ${snapshot.connectionState}'), ); }}
/// FIXED — the Future is created exactly once, in initState, and the SAME/// instance is handed to FutureBuilder on every subsequent build.class FixedFutureWidget extends StatefulWidget { const FixedFutureWidget({super.key});
@override State<FixedFutureWidget> createState() => _FixedFutureWidgetState();}
class _FixedFutureWidgetState extends State<FixedFutureWidget> { late final Future<int> _future;
@override void initState() { super.initState(); _future = fetchValue(); // created once, survives rebuilds }
@override Widget build(BuildContext context) { return FutureBuilder<int>( future: _future, // same instance every build builder: (context, snapshot) => Text('state: ${snapshot.connectionState}'), ); }}import 'dart:async';import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/async-and-performance__futures-streams-in-ui/future_stream_demo.dart';
void main() { testWidgets( 'creating the Future inside build re-fires the work on every rebuild', (tester) async { fetchCallCount = 0; final rebuildTrigger = ValueNotifier<int>(0); await tester.pumpWidget(MaterialApp( home: ValueListenableBuilder<int>( valueListenable: rebuildTrigger, // Deliberately NOT const here: a canonicalized const instance would // let Flutter skip calling build() altogether on an identical // widget, which would hide the trap behind a DIFFERENT (unrelated) // optimization. A genuinely new instance each time is what a real // parent rebuild produces in practice. builder: (context, _, _) => BuggyFutureWidget(), ), )); expect(fetchCallCount, 1);
rebuildTrigger.value++; // forces the parent (and BuggyFutureWidget) to rebuild await tester.pump(); expect(fetchCallCount, 2); // BUG: fetchValue ran again, the request re-fired });
testWidgets('creating the Future once in initState survives rebuilds', (tester) async { fetchCallCount = 0; final rebuildTrigger = ValueNotifier<int>(0); await tester.pumpWidget(MaterialApp( home: ValueListenableBuilder<int>( valueListenable: rebuildTrigger, builder: (context, _, _) => FixedFutureWidget(), ), )); expect(fetchCallCount, 1);
rebuildTrigger.value++; await tester.pump(); expect(fetchCallCount, 1); // FIXED: still just the one call });
testWidgets('ConnectionState walks waiting -> done as the Future settles', (tester) async { await tester.pumpWidget(MaterialApp( home: FutureBuilder<int>( future: delayedFetch(), builder: (context, snapshot) => Text('state: ${snapshot.connectionState}'), ), )); // FutureBuilder has already subscribed by the first frame, so there is // no observable synchronous "none" frame here — it starts at "waiting". expect(find.text('state: ConnectionState.waiting'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 10)); expect(find.text('state: ConnectionState.done'), findsOneWidget); });
testWidgets( 'a Future that completes with an error surfaces via snapshot.hasError, not a thrown exception', (tester) async { // Future.delayed (not an eagerly-completed Future.error) so the error // is only scheduled once FutureBuilder has already subscribed in // initState — an eager Future.error created before pumpWidget can // otherwise race Flutter's own async setup and report as unhandled. await tester.pumpWidget(MaterialApp( home: FutureBuilder<int>( future: Future<int>.delayed( const Duration(milliseconds: 10), () => throw Exception('boom'), ), builder: (context, snapshot) { if (snapshot.hasError) return const Text('handled: hasError'); return const Text('no error yet'); }, ), )); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('handled: hasError'), findsOneWidget); expect(tester.takeException(), isNull); // FutureBuilder swallowed it into the snapshot });
testWidgets( 'throwing directly inside the builder callback is NOT caught by FutureBuilder', (tester) async { await tester.pumpWidget(MaterialApp( home: FutureBuilder<int>( future: Future<int>.value(1), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { throw StateError('thrown inside builder, not the future'); } return const SizedBox(); }, ), )); await tester.pump(); expect(tester.takeException(), isA<StateError>()); // genuinely propagates as a build error });
testWidgets('StreamBuilder shows initialData until the first real event arrives', (tester) async { final controller = StreamController<int>(); await tester.pumpWidget(MaterialApp( home: StreamBuilder<int>( stream: controller.stream, initialData: 0, builder: (context, snapshot) => Text('value: ${snapshot.data}'), ), )); expect(find.text('value: 0'), findsOneWidget); // initialData, stream hasn't emitted yet
controller.add(5); await tester.pump(); expect(find.text('value: 5'), findsOneWidget); await controller.close(); });}00:00 +0: creating the Future inside build re-fires the work on every rebuild00:00 +1: creating the Future once in initState survives rebuilds00:00 +2: ConnectionState walks waiting -> done as the Future settles00:00 +3: a Future that completes with an error surfaces via snapshot.hasError, not a thrown exception00:00 +4: throwing directly inside the builder callback is NOT caught by FutureBuilder00:00 +5: StreamBuilder shows initialData until the first real event arrives
00:00 +6: All tests passed!The first two tests are the trap itself, proven, not asserted: BuggyFutureWidget genuinely calls fetchValue() a second time when its parent rebuilds (fetchCallCount goes 1 → 2), which means a real network/database call would re-fire — while FixedFutureWidget, with the exact same rebuild forced on it, stays at 1. The fix (initState/late final) is not a style preference; it is the only thing that stops the re-fetch.
Reading the ConnectionState matrix correctly
Section titled “Reading the ConnectionState matrix correctly”ConnectionState has four values, but FutureBuilder only ever produces two of them in practice (a Future is either still running or it is done — there is no “active” streaming state for a one-shot value):
ConnectionState | FutureBuilder | StreamBuilder |
|---|---|---|
none | Only if future itself is null | Only if stream itself is null |
waiting | Subscribed, no result yet | Subscribed, no event yet |
active | (never produced) | At least one event has arrived, stream still open |
done | The future completed (value or error) | The stream closed |
The test above shows waiting on the first frame (not none) because delayedFetch() was already a non-null Future by the time FutureBuilder subscribed — none only shows up for a literal future: null, which is rare in practice since you usually have a concrete future or you wouldn’t be using FutureBuilder at all.
hasError vs. a thrown exception — a real, different failure mode
Section titled “hasError vs. a thrown exception — a real, different failure mode”These look like the same thing and are not: snapshot.hasError is Flutter’s own bookkeeping for a Future/Stream that completed with an error — FutureBuilder catches that internally and hands you AsyncSnapshot.hasError == true, no crash. Throwing inside your own builder callback, by contrast, is a completely different failure mode: nothing in FutureBuilder catches it, and it propagates as a real Flutter build error, exactly the same as throwing inside any other widget’s build.
The last two tests above prove this directly: tester.takeException() returns null for the error-future case (Flutter’s test framework confirms nothing escaped uncaught — the error is fully absorbed into snapshot.error) but returns the real StateError for the throw-inside-the-builder case. If you see a red error screen crash from a FutureBuilder, the bug is almost always a throw (or a ! on a null) written directly in the builder callback, not an error from the future itself.
StreamBuilder’s initialData
Section titled “StreamBuilder’s initialData”initialData fills the snapshot’s .data for the frame(s) before the stream’s first real event — proven above: the widget shows value: 0 immediately (before anything was ever added to the StreamController), then updates to value: 5 the instant an event arrives. Without initialData, that first frame would have snapshot.data == null and connectionState == ConnectionState.waiting instead.
Async gaps and mounted
Section titled “Async gaps and mounted”Anything that awaits inside a State — including the future you hand a FutureBuilder, or work you do after one settles — can resume after the widget has been removed from the tree. Calling setState (or reading context) past that point throws a real, proven runtime error — this is covered in full, with the actual setState() called after dispose() error text and the mounted guard fix, in the setState & Lifecycle lesson; FutureBuilder/StreamBuilder themselves don’t need a manual mounted check (they hold no State reference of yours to touch after disposal), but any .then()/await code you write around them does.
The alternative: Riverpod’s AsyncValue
Section titled “The alternative: Riverpod’s AsyncValue”For anything beyond a single local FutureBuilder, Riverpod’s AsyncValue<T> (produced by an AsyncNotifier/FutureProvider) gives you the identical three-state shape — AsyncValue.when(data:, error:, loading:) — but shareable across the widget tree, cacheable, and testable without rebuilding a StatefulWidget by hand. The full mechanics (ProviderScope, .autoDispose, codegen) are covered in the Provider & Riverpod lesson; reach for it once more than one widget needs the same async state, or once you need retry/ref.invalidate()/caching semantics that a raw FutureBuilder doesn’t give you.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated FutureBuilder code reliably falls into the create-in-build trap — it reads naturally (“fetch data, show it”), and the bug is invisible until you actually force a rebuild and watch the network tab or a counter.
// AI-generated: "load the user's profile and show it"class ProfileScreen extends StatelessWidget { const ProfileScreen({super.key, required this.userId}); final String userId;
@override Widget build(BuildContext context) { return Scaffold( body: FutureBuilder<Profile>( future: ProfileRepository().fetchProfile(userId), builder: (context, snapshot) { if (!snapshot.hasData) return const CircularProgressIndicator(); return ProfileView(profile: snapshot.data!); }, ), ); }}Before trusting generated FutureBuilder/StreamBuilder code like this, check for:
- The future/stream expression called directly inside
build(as above,ProfileRepository().fetchProfile(userId)) — proven above, this re-fires the request on every rebuild, not just the first one. !snapshot.hasDataused as the only loading check — it’s alsotruewhilesnapshot.hasErroristruewith no data, so an error state can silently render a spinner forever instead of an error message. CheckhasErrorexplicitly, and beforehasData.snapshot.data!used without confirmingconnectionState == ConnectionState.done(orhasData) first — anullcheck operator on anullvalue from thewaitingframe is a crash, not a graceful loading state.- A brand-new repository instance (
ProfileRepository()) constructed inline — beyond the create-in-build trap, this also makes the widget unmockable in a test (see the testing lesson for injecting a fake repository instead).
🐛 Spot the bug
class SearchResults extends StatelessWidget {const SearchResults({super.key, required this.query});final String query;
@overrideWidget build(BuildContext context) { return FutureBuilder<List<Result>>( future: SearchApi().search(query), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const CircularProgressIndicator(); } return ListView( children: snapshot.data!.map((r) => Text(r.title)).toList(), ); }, );}}Show the answer
Two bugs, both proven above. First, SearchApi().search(query) is called directly inside build — every rebuild (e.g. a parent theme change, or this widget’s own parent rebuilding for an unrelated reason) fires a brand-new search request and resets the results to loading, exactly the create-in-build trap. Second, snapshot.data! is reached once connectionState == ConnectionState.done, but done also covers the error case — if search() throws, snapshot.data is null and this crashes with a null-check-operator error instead of showing anything useful; hasError needs its own branch before this line. The fix: hoist the future into initState/late final (a StatefulWidget, or lift it into a provider), and check snapshot.hasError before touching snapshot.data!.
Mental Model
Section titled “Mental Model”FutureBuilder/StreamBuilder are not “async helpers” so much as subscription widgets: you give them a source, and every frame they render is purely a function of the latest AsyncSnapshot from that source — never of anything they compute themselves. That’s exactly why identity matters so much: the widget doesn’t care what the future contains, it cares whether it’s the same future instance as last time, because that identity is the only signal it has for “is this still the same request, or a new one?” Recreate the instance, and as far as the widget is concerned, a brand-new async operation just started — because, proven above, one genuinely did.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
FutureBuilder (local) | Zero extra state layer, obvious for one-off screens | Proven above: easy to fall into the create-in-build trap; not shareable across widgets |
StreamBuilder + initialData | No waiting flash for a value you already have | The initial value can go stale if it doesn’t match what the stream will actually emit first |
Riverpod AsyncValue | Shared, cached, testable without a StatefulWidget; ref.invalidate() for retry | An extra dependency and a provider to wire up (ProviderScope, see the Riverpod lesson) |
Throwing inside builder (vs. a caught snapshot.hasError) | None — this is a bug, not a real choice | Proven above: an uncaught build error, not a handled state |
Common Misconceptions
Section titled “Common Misconceptions”- “
FutureBuilderre-runs the future every time it rebuilds because that’s just how it works.” Disproven above: it re-runs the future only because a new future instance was passed in —FixedFutureWidget, given the identical forced rebuild, stayed at exactly one call. - “
!snapshot.hasDatais a safe loading check.” Disproven above by the distinction betweenhasErrorand a thrown exception:hasDatais also false in the error case, so relying on it alone as your only branch can hide a real error behind an infinite spinner. - “An error inside a
FutureBuilderalways crashes the app, same as any other Flutter error.” Disproven above: an error the future itself completes with is fully absorbed intosnapshot.hasError/snapshot.error—tester.takeException()returnsnullfor it. Only athrowwritten directly inside yourbuildercallback propagates as an uncaught build error. - “
ConnectionState.noneis the normal starting state for aFutureBuilder.” Disproven above: with a real, non-null future (the overwhelmingly common case), the very first frame is alreadywaiting—noneis reserved for a literalfuture: null.