skip to content

In Flutter, is ChangeNotifier.notifyListeners() synchronous, and what happens when you call it while the widget tree is being built?

level: seniorimportance: nice to knowfreq 22%

answer

  1. runs listeners immediately, in order
  2. a throwing listener does not stop others
  3. markNeedsBuild mid-build
  4. only descendants may be dirtied
  5. move it to a handler or post-frame

basics

~20 s

Yes. notifyListeners() calls every listener immediately, in registration order. Called from a build method, the listening builders try to mark themselves dirty mid-build, and unless they are descendants of the widget building, debug mode throws 'setState() or markNeedsBuild() called during build.'

solid answer

~40 s

`notifyListeners()` is plain synchronous code: it loops over the listeners registered when the call started and calls each one before returning. Listeners added during the loop are skipped, removed ones are not called after removal, and an exception in one listener is reported with `FlutterError.reportError` while the rest still run. For a `ListenableBuilder` or `ValueListenableBuilder`, the listener calls `setState`, which calls `markNeedsBuild()`. If that happens while the framework is building, for instance because a `build` method assigns `notifier.value`, the debug assertion throws "setState() or markNeedsBuild() called during build" unless the dirtied element is a descendant of the one being built. The fix is to change state in event handlers or async callbacks, or defer it with `WidgetsBinding.instance.addPostFrameCallback`; `build` should only read state.

code

dart · 14 lines
dart
import 'package:flutter/foundation.dart';

void main() {
  final status = ValueNotifier<String>('queued');
  final log = <String>[];
  status.addListener(() => log.add('first:${status.value}'));
  status.addListener(() => log.add('second:${status.value}'));

  status.value = 'downloading';
  log.add('after assignment');
  print(log); // [first:downloading, second:downloading, after assignment]

  status.dispose();
}

go deeper

for a junior

Know that setting a notifier's value inside a build method is wrong and that the error names setState or markNeedsBuild during build.

for a middle

Explain that notifyListeners() runs listeners immediately, while the builder only marks itself dirty and rebuilds on the next frame.

for a senior

Read the error's two named widgets to find the offending build, explain the descendant exception, and choose between moving the write and a post-frame callback.

for a principal

Set the rule that build stays read-only across the codebase and review models whose getters or constructors notify as a side effect.

## notifyListeners() runs now There is no queue, microtask or frame scheduling inside `ChangeNotifier`. `notifyListeners()` does the following, synchronously: 1. Returns immediately if there are no listeners. 2. Records how many listeners exist *now* and iterates only over those, so a listener added during the loop is not called in this pass. 3. Calls each listener in **registration order**. A listener removed during the loop is not called after its removal; the list is compacted only once the outermost notification finishes, which is what makes re-entrant calls safe. 4. Wraps each call in `try`/`catch` and reports failures through **`FlutterError.reportError`**, so one throwing listener does not stop the others. The method is `@protected` and `@visibleForTesting`: it is meant to be called by the notifier's own methods, not by widgets. Because it is synchronous, `notifier.value = x; print(listenerRan);` observes the listener's effect on the very next line. What is *not* immediate is the rebuild. ## From notification to rebuild The builders' listener is a small method that calls `setState`. `setState` runs its callback and then calls **`markNeedsBuild()`** on the builder's element, which adds the element to the build owner's dirty list. The actual `build` runs during the next frame's build phase. Two consequences: - Several notifications before the next frame mark the element dirty **once**; `markNeedsBuild()` returns early for an element that is already dirty, so the builder rebuilds once per frame. - The listener has done its job the moment it marks the element; `notifyListeners()` returns long before any widget rebuilds. ## Calling it during build The trouble starts when state changes *while the framework is already building*, typically because a `build` method assigns `notifier.value = ...` or calls a model method that notifies: ```dart @override Widget build(BuildContext context) { download.status.value = DownloadStatus.downloading; // wrong place return const DownloadHeader(); } ``` During the build phase `markNeedsBuild()` checks, in debug builds, whether the element being dirtied is a **descendant of the element currently building**: | Dirtied element | Result in debug mode | |---|---| | A descendant of the widget being built | allowed: it will be built later in this pass anyway | | An ancestor, sibling or unrelated widget | throws `setState() or markNeedsBuild() called during build.` | The error message names both the widget that was marked and the one being built, which points straight at the offending `build`. Release builds skip the assertion; the error's own description warns that otherwise the framework might not visit the dirtied widget during this build phase, so the bug hides rather than disappears. ## Fixes - **Change state in response to events**: button callbacks, gesture handlers, the download's progress callback, a `Future`'s continuation. These run outside the build phase. - **Initialise in `initState`**, not `build`, when a notifier needs a starting value derived from the widget. - **Defer** when you truly must react to something discovered during build: `WidgetsBinding.instance.addPostFrameCallback((_) => status.value = next);` runs after the current frame completes. - Keep `build` a **pure function of state**: read `notifier.value`, never write it. ## What to say in an interview - `notifyListeners()` is synchronous; the rebuild it causes is not. - A listener's exception is reported, not propagated, and does not stop the remaining listeners. - Notifying during build is only legal toward descendants, and it is a design smell even then. - Multiple notifications within a frame coalesce into one rebuild of each builder.

  • In Flutter, what happens to other listeners if one ChangeNotifier listener throws?
    `notifyListeners()` catches the exception, reports it through `FlutterError.reportError` with context naming the notifier, and continues with the next listener. The exception is not rethrown to the code that called `notifyListeners()`.
  • In Flutter, how can you safely update a notifier based on something computed during build?
    Schedule the update with `WidgetsBinding.instance.addPostFrameCallback`, which runs after the current frame, so the resulting `markNeedsBuild()` happens outside the build phase. Better still, compute the value where the triggering event happens, in `initState` or a callback, and keep `build` read-only.

saying these in an interview costs you the question

  • notifyListeners() schedules listeners for the next frame
  • Listeners added inside a listener run in the same notification pass
  • One throwing listener aborts the remaining listeners
  • Notifying during build is always safe because rebuilds are batched
  • Every notification triggers its own immediate rebuild