skip to content

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?

level: middleimportance: must knowfreq 55%

answer

  1. setState rebuilds that State's subtree
  2. move the ticking state down
  3. a small StatefulWidget owns the timer
  4. const for everything static
  5. or a ValueNotifier with a builder

basics

~20 s

setState 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 lines
dart
import '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

for a junior

Remember that setState rebuilds the subtree of the State that calls it, so ticking state belongs in the smallest widget that shows it.

for a middle

Explain why a helper method does not narrow a rebuild but an extracted widget does, and when a ValueNotifier with a builder fits better.

for a senior

Prove the fix with Track Widget Builds in profile mode, and design shared controllers so buttons trigger changes without listening to them.

for a principal

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