A Flutter stopwatch page calls setState in its top-level State every 100 ms, rebuilding the whole Scaffold; how do you shrink each rebuild to the elapsed-time text?
answer
- setState rebuilds that State's subtree
- move the ticking state down
- a small StatefulWidget owns the timer
- const for everything static
- or a ValueNotifier with a builder
basics
~20 ssetState rebuilds the whole subtree of the State that calls it. Move the timer and elapsed value into a small StatefulWidget that renders only the time text, or into a ValueNotifier read by one builder, and make the static rest const.
solid answer
~40 s`setState` marks only its own element dirty, but that element's `build` recreates every non-const widget below it. With the timer in the page's `State`, each tick rebuilds the `Scaffold`, `AppBar`, buttons and lap list. The fix is to **push the state down** to the smallest subtree that shows it. Extract an `ElapsedText` `StatefulWidget` that owns the `Timer.periodic`, cancels it in `dispose`, and calls `setState` to rebuild only its `Text`. The page becomes a `StatelessWidget` whose children are `const`. Alternatively, keep the elapsed time in a `ValueNotifier<Duration>` and wrap only the text in a `ValueListenableBuilder`. Either way, the start, stop and lap buttons talk to shared state, not to the page's `setState`.
code
dart · 46 linesimport 'dart:async';
import 'package:flutter/material.dart';
class StopwatchPage extends StatelessWidget {
const StopwatchPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Stopwatch')),
body: const Column(children: [ElapsedText(), Text('Laps appear here')]),
);
}
}
class ElapsedText extends StatefulWidget {
const ElapsedText({super.key});
@override
State<ElapsedText> createState() => _ElapsedTextState();
}
class _ElapsedTextState extends State<ElapsedText> {
final Stopwatch _watch = Stopwatch()..start();
late final Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final Duration e = _watch.elapsed;
final String seconds = (e.inSeconds % 60).toString().padLeft(2, '0');
return Text('${e.inMinutes}:$seconds.${(e.inMilliseconds % 1000) ~/ 100}');
}
}go deeper
Remember that setState rebuilds the subtree of the State that calls it, so ticking state belongs in the smallest widget that shows it.
Explain why a helper method does not narrow a rebuild but an extracted widget does, and when a ValueNotifier with a builder fits better.
Prove the fix with Track Widget Builds in profile mode, and design shared controllers so buttons trigger changes without listening to them.
Set conventions for where frequently changing state lives, so hot screens do not grow whole-page rebuilds as features accrete.
## Why the whole page rebuilds `setState` does two things. It runs your callback, then it marks **that `State`'s element** dirty. On the next frame the framework calls that `State`'s `build`, and every widget it returns is a new instance unless it is `const` or otherwise reused. `Element.updateChild` then updates each child element whose widget changed, and each of those runs its own `build`. The rebuild spreads down until it reaches identical instances. So the reach of a `setState` is decided by **where the `State` sits**, not by what changed. A stopwatch that keeps its `Timer` and `Duration` in the page's `State` rebuilds these ten times a second: - the `Scaffold` and `AppBar`; - the start, stop and lap buttons, and every widget inside them; - the lap history list; - the one `Text` whose content actually changed. ## Fix 1: push the state down Move the ticking state into the smallest widget that displays it: 1. Create an `ElapsedText` `StatefulWidget` whose `State` owns a `Stopwatch` and a `Timer.periodic`. 2. Start the timer in `initState` and cancel it in `dispose`. 3. In the tick callback, call `setState` so only `ElapsedText`'s `build`, a single `Text`, reruns. 4. Make the page a `StatelessWidget` and its static children `const`, so nothing above `ElapsedText` rebuilds. Start, stop and lap buttons need to reach the stopwatch. Put a shared object, such as a `ChangeNotifier` controller, above both, and let only the widgets that display changing values listen to it. ## Fix 2: a notifier and a builder Keep the elapsed time in a `ValueNotifier<Duration>` owned higher up, and wrap only the text in `ValueListenableBuilder<Duration>`. The notifier's listener calls `setState` inside the builder's own small `State`, so the rebuild is local to it. Pass any static decoration through the builder's **`child`** parameter so it is not rebuilt on each tick. | Approach | Where the tick lands | Good when | |---|---|---| | state in the page's `State` | whole page | never, for a 10 Hz tick | | extracted `ElapsedText` `StatefulWidget` | one `Text` | only that widget needs the value | | `ValueNotifier` + `ValueListenableBuilder` | the builder's subtree | several widgets or buttons share the value | | state library with select-style reads | the widgets that read the field | the app already uses one | ## Why a helper method does not help Moving the time text into a `Widget _buildTime()` method on the page's `State` changes nothing. The method still runs inside the page's `build`, so it is part of the same rebuild. Only a separate **widget**, with its own element, gives the framework a boundary where a rebuild can start and stop. The general extract-widget-versus-helper-method question has its own home. Here, the point is that the **state** must move, not just the code. ## Checking the result - Turn on **Track Widget Builds** in the DevTools performance view, run in profile mode, and let the stopwatch tick. Before the fix, each frame shows `build` events for the page's whole subtree. After it, a tick shows only `ElapsedText` and its `Text`. - `debugPrintRebuildDirtyWidgets = true` in a debug run prints `Rebuilding ...` for each widget per frame, which is a quick console check. ## Choosing between the fixes - **One widget needs the value.** An extracted `StatefulWidget` is the simplest option, and nothing outside it knows the timer exists. - **Several widgets need it, or buttons change it.** A notifier owned above them, with small listening widgets, keeps one source of truth. - **The app already uses a state library.** Use its equivalent of a narrow listener, so only the widgets that read the elapsed field rebuild. - **In every case, keep the static widgets `const`.** That stops the page's own rebuilds, whatever they are for, from reaching them. ## Related traps - **A `Timer` left running.** Cancel it in `dispose`, or it calls `setState` on an unmounted `State`. - **Rebuilding more often than the display changes.** A 100 ms tick for a display of tenths is fine. A 1 ms timer rebuilds far more often than frames are drawn. - **Moving state down too far.** If three widgets need the value, one shared notifier beats three timers drifting apart.
- How do the stopwatch's start and stop buttons control the timer once it lives in ElapsedText?Lift the stopwatch into a shared controller, such as a `ChangeNotifier` or a `ValueNotifier`, created above both the buttons and the display. The buttons call methods on it and do not listen. Only the display listens and rebuilds. The page itself never calls `setState`, so the buttons are not rebuilt on each tick.
- Is rebuilding the whole page ten times a second actually a problem on a fast phone?Often not visibly at first, but it spends frame budget on `build` work that produces identical output, and the cost grows with everything later added to the page. Measure with Track Widget Builds in profile mode; if the UI thread's build time grows with the page, the fix is to narrow the rebuild, not to buy headroom.
saying these in an interview costs you the question
- setState rebuilds only the widget whose value changed
- Moving the text into a _buildTime() helper method limits the rebuild
- setState rebuilds the entire app from the root widget
- A Timer.periodic in a State needs no cleanup in dispose
- Rebuilds are free because Flutter diffs the widget tree