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?
answer
- move progress out of the screen's State
- ValueNotifier<double> for the fraction
- wrap only the indicator
- Listenable.merge for two sources
- create merge once, not in build
basics
~20 sKeep 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 sThe 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 linesimport '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
Recall that setState rebuilds the whole widget that owns it, while a ValueListenableBuilder rebuilds only what its builder returns.
Explain moving progress into a ValueNotifier, wrapping only the indicator, and how Listenable.merge forwards listener registrations to every child.
Diagnose the jank as rebuild scope, restructure ownership and disposal of the controller, create merged listenables once, and verify with rebuild counts.
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