Skip to content
Blog

StreamBuilder and FutureBuilder: Reactive Async UI in Flutter

How AsyncSnapshot states drive loading, error, and data UI in Flutter — and the two rules that keep FutureBuilder from flickering or refetching on every rebuild.

Published on • October 12, 2026

AI Assistant

Every Flutter app eventually grows a widget whose whole job is to render “nothing yet”, “here it is”, or “it broke”. Hand-rolling that means three booleans, a setState call in a .then(), and an error path you forgot. FutureBuilder and StreamBuilder exist to delete that boilerplate: you hand them an async computation, they turn each interaction into an AsyncSnapshot, and your builder just switches on it.

Both widgets share the same machinery, and the API docs for FutureBuilder even say so: a FutureBuilder “behaves identically to a StreamBuilder configured with future?.asStream(), except that snapshots with ConnectionState.active may appear for the latter.” Learn the snapshot model once and you know both widgets.

The snapshot model

An AsyncSnapshot is an immutable record of the most recent interaction with the async computation. It carries four things: connectionState, data, error (with a matching stackTrace), and the derived hasData / hasError / requireData accessors. Two of those deserve emphasis:

  • hasData is literally “data is non-null” — not “the operation succeeded”. A Future<String?> that completes with null has no data, so your type choice affects what the widget reports.
  • hasError is “error is non-null” — it flips the moment the stream or future throws, and stays true in the done snapshot for a failed future.

ConnectionState is the second axis, and its documented flow is none → waiting → active → done:

  • none — not connected to any computation (for example, a FutureBuilder whose future is null).
  • waiting — connected and awaiting interaction, typically with null data.
  • active — connected to an active computation. A Stream that has produced at least one value but isn’t finished. This state is the reason StreamBuilder exists.
  • done — connected to a terminated computation.

The subtlety the API docs call out explicitly: the builder “is called at the discretion of the Flutter pipeline”, so it receives a timing-dependent sub-sequence of snapshots. For a stream emitting 0 through 9 you might see waiting, active(0), active(5), done(9) — the done snapshot is guaranteed to arrive, the intermediate ones are not. That’s a feature (you skip frames you can’t afford to render), not a bug, but it means you cannot treat every snapshot as a complete replay.

StreamBuilder over a live stream

Create the stream somewhere that runs once — initState, a controller, a repository — never inside build. Then:

class _ClockState extends State<Clock> {
  late final Stream<int> _ticks;

  @override
  void initState() {
    super.initState();
    _ticks = Stream<int>.periodic(
      const Duration(seconds: 1),
      (i) => i,
    ).take(60);
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<int>(
      stream: _ticks,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Text('Clock failed: ${snapshot.error}');
        }
        switch (snapshot.connectionState) {
          case ConnectionState.none:
            return const Text('Not connected');
          case ConnectionState.waiting:
            return const CircularProgressIndicator();
          case ConnectionState.active:
            return Text('Tick ${snapshot.data}');
          case ConnectionState.done:
            return Text('Stopped at ${snapshot.data}');
        }
      },
    );
  }
}

Swap _ticks for channel.stream from a web_socket_channel connection and the same widget renders a live chat feed — the WebSocket cookbook uses exactly this shape, with snapshot.hasData ? '${snapshot.data}' : '' as the minimal builder. For a stream with a meaningful first value, pass initialData so the very first frame renders content instead of null; the docs note the builder always runs before the listener has a chance to deliver an event.

FutureBuilder over an HTTP fetch

The fetch-data cookbook lays out the canonical version, and it starts before the widget:

class _ProfileState extends State<Profile> {
  late Future<Album> futureAlbum;

  @override
  void initState() {
    super.initState();
    futureAlbum = fetchAlbum();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<Album>(
      future: futureAlbum,
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return Text(snapshot.data!.title);
        } else if (snapshot.hasError) {
          return Text('${snapshot.error}');
        }
        return const CircularProgressIndicator();
      },
    );
  }
}

With fetchAlbum() doing the request and — critically — throwing on a non-200 status:

Future<Album> fetchAlbum() async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),
  );
  if (response.statusCode == 200) {
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  }
  throw Exception('Failed to load album');
}

The cookbook is blunt about why: throwing sets snapshot.hasError, and returning null instead would leave hasData false forever with no error to show — an infinite spinner. Error handling isn’t a nicety here; it’s what makes the loading branch terminate.

The rule that saves you from flicker

Both API docs say the same sentence, with the force of a warning: the future or stream “must not be created during the State.build or StatelessWidget.build method call”. Create it in initState, didUpdateWidget, or didChangeDependencies. The reason given is precise — if the async task is created at the same time as the builder, “every time the [builder]‘s parent is rebuilt, the asynchronous task will be restarted.”

For FutureBuilder this produces the classic flicker bug: a spinner, data, spinner, data, on every scroll-driven rebuild, hammering your API. The cookbook spells out the cost: build() runs every time Flutter changes anything in the view, so a call placed there “is repeatedly called on each rebuild causing the app to slow down”. Caching it in a late Future<...> field executed once is the entire fix.

A related trap the FutureBuilder docs warn about: handing the widget a new but already-completed future still produces one frame in ConnectionState.waiting, because there is no way to synchronously determine a Future has already completed. That’s another reason the future must be a stable reference rather than a freshly created one.

Pitfalls worth internalizing

  • Check hasError before hasData, and check both explicitly. The default snapshot has null data, so a builder that only reads snapshot.data! will throw in the loading state.
  • A stream error is not the end. StreamBuilder delivers withError(ConnectionState.active, ...) and the stream can keep emitting; FutureBuilder delivers withError(ConnectionState.done, ...) and it’s over. Your UI should reflect that difference.
  • ConnectionState.done still has data — the last value, not null. For a list, that’s the frame where you show “complete”, not “empty”.
  • StreamBuilder’s stream can change between builds. Passing a different stream produces none then waiting snapshots; that’s normal, but it means passing an unstable stream reference resets your UI state.
  • FutureBuilder.debugRethrowError is a static toggle that rethrows the latest error instead of swallowing it — useful when an error seems to vanish into the snapshot and you want a real stack trace in the debugger.

When to stop using builders

Builders are perfect for ephemeral async state tied to one screen: a detail fetch, a one-shot form submission, a stream this widget listens to and nothing else. They fall apart as soon as the same async state is needed in more than one place, needs to outlive the widget, or needs to be recomputed on demand (pull-to-refresh, a retry button, a filter change). At that point you’re passing futures down through constructors and refetching in didUpdateWidget — the smell that it’s time for a state management layer.

The Flutter docs’ own state management guide frames the trade-off as ephemeral vs app state. Riverpod’s FutureProvider / StreamProvider and Bloc’s AsyncSnapshot-style states solve exactly the problems builders expose: caching, deduplication, dependency-aware invalidation, and exposing one async result to many widgets without each one owning a Future. The builders don’t go away — a Riverpod consumer is still a StreamBuilder underneath — but the ownership of the async work moves out of the widget tree, which is where it belongs once it stops being local.

Closing

FutureBuilder and StreamBuilder are the right tool for single-widget async UI, and the entire contract fits on an index card: create the async work outside build, render waiting as a spinner, hasError as an error state, and the rest as data. Get those right and the widgets are invisible, which is exactly what a good abstraction should be.

References