skip to content

In a Flutter sheet-music app, a score download's progress callback calls setState on the whole screen many times a second and the page janks; how would you restructure it with notifiers and builders?

level: seniorimportance: should knowfreq 34%

answer

  1. move progress out of the screen's State
  2. ValueNotifier<double> for the fraction
  3. wrap only the indicator
  4. Listenable.merge for two sources
  5. create merge once, not in build

basics

~20 s

Keep the progress in a ValueNotifier<double> owned by the download controller, and wrap only the LinearProgressIndicator in a ValueListenableBuilder. Combine progress with status via Listenable.merge, built once, so each tick rebuilds a few widgets instead of the screen.

solid answer

~40 s

The jank comes from rebuild scope: `setState` in the screen's `State` re-runs its whole `build`, score thumbnails and all, on every progress tick. Move the changing values into notifiers owned by a download controller, for example `ValueNotifier<double> progress` and `ValueNotifier<DownloadStatus> status`, and have the download callback assign `progress.value`. Wrap only the `LinearProgressIndicator` and its label in `ValueListenableBuilder<double>`; if one widget needs both values, use `ListenableBuilder(listenable: Listenable.merge([progress, status]), ...)` with the merged listenable created once in `initState`, not in `build`. The rest of the screen stops rebuilding. The controller's owner disposes both notifiers; the merged listenable needs no disposal because it only forwards listener registrations. Confirm the win with the rebuild counts in DevTools.

code

dart · 54 lines
dart
import 'package:flutter/material.dart';

enum DownloadStatus { queued, downloading, paused, done }

class ScoreDownload {
  final ValueNotifier<double> progress = ValueNotifier<double>(0);
  final ValueNotifier<DownloadStatus> status =
      ValueNotifier<DownloadStatus>(DownloadStatus.queued);

  void dispose() {
    progress.dispose();
    status.dispose();
  }
}

class DownloadSheet extends StatefulWidget {
  const DownloadSheet({super.key});

  @override
  State<DownloadSheet> createState() => _DownloadSheetState();
}

class _DownloadSheetState extends State<DownloadSheet> {
  final ScoreDownload _download = ScoreDownload();
  late final Listenable _both =
      Listenable.merge([_download.progress, _download.status]);

  @override
  void dispose() {
    _download.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('Goldberg Variations, full score'),
        ValueListenableBuilder<double>(
          valueListenable: _download.progress,
          builder: (context, value, child) =>
              LinearProgressIndicator(value: value),
        ),
        ListenableBuilder(
          listenable: _both,
          builder: (context, child) => Text(
            '${_download.status.value.name} '
            '${(_download.progress.value * 100).round()}%',
          ),
        ),
      ],
    );
  }
}

go deeper

for a junior

Recall that setState rebuilds the whole widget that owns it, while a ValueListenableBuilder rebuilds only what its builder returns.

for a middle

Explain moving progress into a ValueNotifier, wrapping only the indicator, and how Listenable.merge forwards listener registrations to every child.

for a senior

Diagnose the jank as rebuild scope, restructure ownership and disposal of the controller, create merged listenables once, and verify with rebuild counts.

for a principal

Decide when hand-rolled notifier controllers stay maintainable for such flows and when the team's state library should own long-running operations instead.

## Why the screen janks The original code keeps `double _progress` in the screen's `State` and calls `setState(() => _progress = p)` from the download callback. `setState` marks that `State`'s element dirty, so the **entire `build` method** of the screen runs again on every tick: the `Scaffold`, the score header, a grid of cover thumbnails, perhaps a `ListView` of parts. A download can report progress dozens of times per second. Each rebuild is cheap on its own, but rebuilding a large subtree every frame competes with layout and paint for the frame budget, and the page stutters. The fix is not to rebuild less often; it is to **rebuild less**. Only two widgets care about the progress. ## Step 1: move the changing state into notifiers Create a small controller that owns the download's observable state: - `final ValueNotifier<double> progress = ValueNotifier<double>(0);` - `final ValueNotifier<DownloadStatus> status = ValueNotifier<DownloadStatus>(DownloadStatus.queued);` - a `dispose()` that disposes both. The download code assigns `progress.value = received / total`. Because `ValueNotifier` skips notification when the new value is `==` the old one, repeated identical fractions cost nothing. Who owns the controller follows the usual rule: if it is scoped to the download sheet, the sheet's `State` creates it in `initState` and disposes it in `dispose`. If it must outlive the sheet, a longer-lived object owns it and the sheet only listens. ## Step 2: wrap only what reads it ```dart ValueListenableBuilder<double>( valueListenable: controller.progress, builder: (context, value, child) => LinearProgressIndicator(value: value), ) ``` Now a tick marks only this builder dirty. The thumbnails, header and buttons keep their elements and are not rebuilt. ## Step 3: combine sources with Listenable.merge If one widget, say a label reading "Downloading 42%" or "Paused", needs both notifiers, use **`Listenable.merge`**: 1. In `initState`, build `_both = Listenable.merge([controller.progress, controller.status]);` 2. In `build`, use `ListenableBuilder(listenable: _both, builder: (context, child) => Text(label(controller.status.value, controller.progress.value)))`. Facts about `Listenable.merge` worth stating precisely: - It returns a `Listenable` whose `addListener` and `removeListener` simply forward to each child, so a notification from **any** child fires the listener. - `null` entries are ignored. - The iterable must not change after the call; mutating it risks exceptions or listeners left behind. - It is not a `ChangeNotifier` and has no `dispose()`; the children's owners dispose the children. - Created inside `build`, it is a new object on each rebuild, so the builder sees a different listenable and removes and re-adds its listener every time. That churn is avoidable, so create it once. ## Before and after | | `setState` on the screen | notifiers + builders | |---|---|---| | Widgets rebuilt per tick | the whole screen subtree | the indicator and its label | | Where progress lives | screen `State` field | controller's `ValueNotifier` | | Duplicate values | still rebuild | skipped by `==` check | | Testability | needs a widget test | controller testable alone | ## Checking the result - Use the rebuild counts in Flutter DevTools, or a debug print in the thumbnail's `build`, to confirm the rest of the screen no longer rebuilds on each tick. - If ticks still arrive far faster than frames, that is fine: several notifications within one frame mark the builder dirty once, and it rebuilds once per frame. - Keep the builders small; wrapping the whole `Scaffold` in a `ListenableBuilder` recreates the original problem. The principle an interviewer wants to hear: **put changing state in a notifier and put the builder as low in the tree as the widgets that read it**.

  • In Flutter, does the Listenable returned by Listenable.merge need to be disposed?
    No. It only forwards `addListener` and `removeListener` to its children and holds no listener list of its own, so it has no `dispose()`. The notifiers passed into it still need disposing by whoever created them.
  • In Flutter, if a download reports progress 200 times per second, does a ValueListenableBuilder rebuild 200 times per second?
    No. Each notification calls `setState`, which marks the builder dirty, but marking an already dirty element is a no-op, so it rebuilds at most once per frame. Throttling the source can still save work in the notifier's listeners.

saying these in an interview costs you the question

  • Calling setState less often is the only way to stop progress jank
  • Listenable.merge creates a notifier that must be disposed separately
  • Wrapping the whole Scaffold in ListenableBuilder scopes the rebuild
  • Creating Listenable.merge inside build is free because nothing changes
  • Each progress notification forces its own separate frame