skip to content

In Flutter, why does controller.removeListener(_onScroll) work with a method tear-off but silently fail when the listener was added as () => _onScroll()?

level: seniorimportance: should knowfreq 32%

answer

  1. removal matches by ==
  2. same method, same receiver: equal
  3. each lambda is a new object
  4. leftover listener calls a dead State
  5. store the closure in a field

basics

~20 s

ChangeNotifier.removeListener removes the first registered listener that is == to its argument. Two tear-offs of the same method on the same object are equal, so removal works; two separately written lambdas are distinct objects, so nothing is removed and the listener stays attached.

solid answer

~40 s

`ChangeNotifier`, which `ScrollController`, `TextEditingController` and `FocusNode` build on, finds the listener to remove with `==`. Dart defines equality for tear-offs: top-level and static function tear-offs are equal to each other, and two instance-method tear-offs are equal when they name the same method **on the same receiver**. So `addListener(_onScroll)` in `initState` and `removeListener(_onScroll)` in `dispose` match. An anonymous function is a fresh closure each time the expression is evaluated, so `removeListener(() => _onScroll())` passes a new object that equals nothing registered: removal is a silent no-op. If the controller outlives the `State`, the leftover listener keeps the `State` reachable and calls into it after disposal, often surfacing as a `setState() called after dispose()` error. Use a tear-off, or keep the one closure in a field and pass that same object to both calls.

code

dart · 40 lines
dart
import 'package:flutter/widgets.dart';

class ScrollAware extends StatefulWidget {
  const ScrollAware({super.key, required this.controller});

  final ScrollController controller;

  @override
  State<ScrollAware> createState() => _ScrollAwareState();
}

class _ScrollAwareState extends State<ScrollAware> {
  double _offset = 0;

  void _onScroll() => setState(() => _offset = widget.controller.offset);

  @override
  void initState() {
    super.initState();
    widget.controller.addListener(_onScroll);
  }

  @override
  void didUpdateWidget(ScrollAware oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.controller != widget.controller) {
      oldWidget.controller.removeListener(_onScroll);
      widget.controller.addListener(_onScroll);
    }
  }

  @override
  void dispose() {
    widget.controller.removeListener(_onScroll); // equal tear-off: removed
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Text('${_offset.round()}');
}

go deeper

for a junior

Recall that a listener must be removed with the same function that was added, and that a method name without parentheses gives that function.

for a middle

Explain Dart's function equality rules for top-level, static and instance tear-offs versus lambdas, and why ChangeNotifier's removal depends on them.

for a senior

Diagnose setState after dispose and leaks from controllers owned by a parent, and fix them with tear-offs, stored closures and didUpdateWidget rewiring.

for a principal

Establish ownership rules for controllers and listeners in shared widgets, so the widget that adds a listener is always the one that removes it.

## How listeners are removed Flutter's `ChangeNotifier` keeps its listeners in a list. `removeListener(listener)` walks that list and removes the **first entry that is `==`** to the function you pass. It does not throw when nothing matches; it simply does nothing. Many objects you listen to in Flutter are `ChangeNotifier`s or `Listenable`s with the same contract: `ScrollController`, `TextEditingController`, `FocusNode` and `ValueNotifier` among them. So the question "will my listener be removed?" is really "is the function I pass to `removeListener` **equal** to the one I passed to `addListener`?" ## Equality of Dart functions Dart gives tear-offs a defined equality, documented on dart.dev: - **Top-level functions and static methods**: tearing off the same one twice gives equal function objects (`foo == foo`, `A.bar == A.bar`). - **Instance methods**: two tear-offs of the **same method** on the **same object** are equal (`w.baz == w.baz`); tear-offs of the same method on **different** objects are not (`v.baz != w.baz`). - **Anonymous functions**: each evaluation of a function literal produces a new closure. Two lambdas with identical source, created separately, are different objects and are not equal. Inside a `State`, `_onScroll` means `this._onScroll`. Evaluated in `initState` and again in `dispose`, both tear-offs name the same method on the same `State` object, so they are equal and removal succeeds. ## The failure mode ```dart @override void initState() { super.initState(); widget.controller.addListener(() => _onScroll()); } @override void dispose() { widget.controller.removeListener(() => _onScroll()); // removes nothing super.dispose(); } ``` 1. `addListener` stores closure A. 2. `removeListener` receives closure B, a new object; `B == A` is false. 3. Nothing is removed, and no error is reported. 4. If the controller belongs to a parent and outlives this `State`, the next scroll calls closure A, which calls `_onScroll` on a disposed `State`. Any `setState` there fails with a `setState() called after dispose()` error, and closure A keeps the whole `State` and its subtree reachable: a memory leak. The bug is invisible when the controller is owned and disposed by the same `State`, because disposing the controller drops its listeners anyway. It appears when the controller is **shared** or passed in. ## Fixes | Approach | Code | Why it works | |---|---|---| | Method tear-off | `addListener(_onScroll)` / `removeListener(_onScroll)` | same method, same receiver: equal | | Closure in a field | `late final VoidCallback _listener = () => _onScroll();` | the same object is passed both times | | Owned controller | create and `dispose()` it in this `State` | disposal drops all listeners | The tear-off is the idiomatic choice. Keep a field when the listener must capture something a method cannot, and pass that field to both calls. ## Auditing a codebase for it A short review checklist finds these before they ship: - search for `addListener((` and `addListener(() =>`: every hit either needs a matching field-stored closure or should become a tear-off; - pair every `addListener` in `initState` with a `removeListener` in `dispose`, using the **same expression**; - check `didUpdateWidget` wherever the listened-to object comes from `widget`; - in tests, pump the widget, replace it with an empty tree so the `State` is disposed, then notify the controller and expect no exception. Because `removeListener` fails silently, the only symptoms are later errors and leaks, so catching the mismatch in review is far cheaper than debugging it. ## Related cases - When `didUpdateWidget` sees a **new** controller, remove the listener from `oldWidget.controller` and add it to `widget.controller`; tear-off equality makes the removal on the old one work. - `Stream` subscriptions are cancelled through the `StreamSubscription` that `listen` returns, not by passing the callback again, so equality does not matter there.

  • Are two Dart tear-offs of the same instance method on different objects equal?
    No. Instance-method tear-offs are equal only when both the method and the receiver are the same. `v.baz != w.baz` for two distinct objects, which is why a `State` removing `_onScroll` never removes another `State`'s listener by accident.
  • How do you remove a listener in Flutter when it genuinely needs to be a closure?
    Create the closure once and keep it, for example `late final VoidCallback _listener = () => _onScrollFor(widget.id);`, then pass `_listener` to both `addListener` and `removeListener`. Passing the same object guarantees the `==` match that removal needs.

Removing a listener is like cancelling a booking by the exact name it was made under: a tear-off of the same method on the same object gives the same name, while a new lambda is a different guest asking to cancel a booking that was never theirs.

saying these in an interview costs you the question

  • Two lambdas with the same code are equal, so removeListener still works
  • removeListener throws when the listener is not registered
  • Tear-offs of the same method on different objects are equal
  • Disposing the State automatically detaches its listeners from shared controllers
  • Tear-off equality requires the two function objects to be identical