skip to content

In Flutter, when does a StreamBuilder subscribe to and cancel its stream, and what breaks when the stream is created inside build()?

level: seniorimportance: should knowfreq 30%

answer

  1. listen in initState
  2. cancel in dispose
  3. new stream means cancel and relisten
  4. single-subscription listens once
  5. keep the stream in State

basics

~20 s

StreamBuilder listens in initState, cancels in dispose, and when given a different stream cancels the old subscription and listens to the new one. A stream created in build() is replaced on each rebuild, restarting the source and resetting to waiting.

solid answer

~50 s

`StreamBuilder`'s state calls `stream.listen` in `initState` and `cancel()` in `dispose`, so the widget owns its subscription and cleans it up when removed. In `didUpdateWidget`, if the new `stream` is not `==` to the old one, it cancels the old subscription, moves the snapshot to `none` then `waiting`, and listens to the new one. If `build` creates the stream, for example `stream: db.watchForecast(city)` or a `.map(...)` on a source, each rebuild produces a new stream object: the query restarts, events emitted in between are missed, and the UI flickers to `waiting`. With a single-subscription stream kept in a field, the opposite bug appears: a second `StreamBuilder`, or the same one re-created after leaving the tree, calling `listen` again throws a `StateError`. Create or obtain the stream once in `State`, or expose a broadcast stream from its owner.

code

dart · 29 lines
dart
class _ForecastFeedState extends State<ForecastFeed> {
  late Stream<List<String>> _days;

  @override
  void initState() {
    super.initState();
    _days = widget.watch(widget.city);
  }

  @override
  void didUpdateWidget(ForecastFeed oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.city != widget.city) {
      _days = widget.watch(widget.city);
    }
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<String>>(
      stream: _days,
      builder: (context, snapshot) {
        if (snapshot.hasError) return const Text('Feed error');
        if (!snapshot.hasData) return const CircularProgressIndicator();
        return Column(children: [for (final d in snapshot.requireData) Text(d)]);
      },
    );
  }
}

go deeper

for a junior

Remember that StreamBuilder listens and cancels for you, and that the stream must be created once, not inside build.

for a middle

Explain didUpdateWidget's cancel and relisten when the stream object changes, and why a .map in build counts as a new stream.

for a senior

Diagnose restarted queries, missed events and StateErrors on re-created builders, and choose between per-listener, broadcast and cached-state designs.

for a principal

Set where long-lived streams are owned and shared in the app so widgets subscribe to stable sources instead of creating their own.

## The subscription lifecycle `StreamBuilder` extends `StreamBuilderBase`, whose `State` manages a single `StreamSubscription`: | Moment | What `StreamBuilder` does | |---|---| | `initState` | sets the initial snapshot, calls `stream.listen(...)` if the stream is non-null, moves to `waiting` | | each data event | `setState` with an `active` snapshot carrying the value | | each error event | `setState` with an `active` snapshot carrying the error | | stream closes | `setState` to `done` | | `didUpdateWidget` with a different stream | cancels the old subscription, snapshot to `none`, listens to the new stream, `waiting` | | `dispose` | cancels the subscription | Because it cancels in `dispose`, a `StreamBuilder` does not leak its own subscription. What it cannot protect you from is the stream object you give it. ## Creating the stream in build Consider a forecast screen that watches a local database table: ```dart @override Widget build(BuildContext context) { return StreamBuilder<List<Forecast>>( stream: db.watchForecast(widget.city).map(sortByDay), // new stream every build builder: (context, snapshot) => ForecastList(snapshot: snapshot), ); } ``` Each `.map` call returns a **new `Stream` object**, and a `watch...()` method typically does too. On every rebuild, `didUpdateWidget` sees a different stream and: 1. cancels the old subscription, which may tear down a database query or a platform listener; 2. listens to the new stream, which may start the query again; 3. drops the snapshot back to `waiting` with the old data, so the UI can flicker; 4. loses any event emitted between cancel and the new subscription's first event, for sources that do not replay. The fix mirrors the `FutureBuilder` rule: obtain the stream in `initState`, re-create it in `didUpdateWidget` when an input such as `widget.city` changes, and pass the stored field. ## The single-subscription trap Many Dart streams, including the stream of a default `StreamController`, are **single-subscription**: they allow one `listen` for their whole life. Storing such a stream in a field fixes the rebuild problem, but two situations call `listen` again: - Two `StreamBuilder`s given the same single-subscription stream. - A `StreamBuilder` that leaves the tree and comes back, for instance in a tab that is not kept alive, creating a new `State` that listens to the stored stream a second time. The second `listen` throws a `StateError`. Options: - Give each listener its own stream by calling the owner's `watch...()` method per subscriber, in that subscriber's `State`. - Have the owner expose a **broadcast** stream (for example from a broadcast `StreamController`), accepting that late listeners miss earlier events. - Keep the data in a notifier or state library that caches the latest value, so late listeners still get it. The details of single-subscription versus broadcast streams belong to Dart's stream semantics; what matters here is recognising the error when a `StreamBuilder` is re-created. ## Checklist - Never build a stream, or chain `.map`/`.where` onto one, inside `build`. - Re-create the stream only when its inputs change, in `didUpdateWidget` or `didChangeDependencies`. - Let `StreamBuilder` own the subscription; do not also `listen` manually to the same stream. - For streams shared across widgets, decide explicitly between per-listener streams, broadcast streams and a cached state holder. - Remember that `initialData` only covers the first frames; it does not replay events a new subscription missed.

  • In Flutter, do you need to cancel a StreamBuilder's subscription yourself?
    No. `StreamBuilder` cancels its subscription in its own `dispose` and when it switches to a different stream. You still own the stream's source, such as a `StreamController` you created, and must close that in your own `State.dispose`.
  • In Flutter, why can a StreamBuilder inside a tab throw a StateError when you return to the tab?
    If the tab's subtree was disposed, returning creates a new `StreamBuilder` state that calls `listen` on the stored stream again. A single-subscription stream allows only one listen, so it throws. Create the stream per subscriber, use a broadcast stream, or keep the tab alive.

saying these in an interview costs you the question

  • StreamBuilder never cancels, so every one leaks a subscription
  • Chaining .map onto a stream inside build is harmless
  • Any stream can be listened to by several StreamBuilders
  • initialData replays events missed while resubscribing
  • StreamBuilder resubscribes on every rebuild even with the same stream