Skip to content

avoid_unremovable_callbacks_in_listeners

v1.0.0WarningConfigurableResource Management

This rule flags a closure literal passed to addListener. removeListener matches by identity, and a closure creates a new object each time it is evaluated — so the listener can never be removed.

removeListener(theClosure) compares object identity. A closure literal written at the call site is a different object from any closure you could later pass, so the removal silently does nothing and the listener stays registered.

Two consequences follow. The listener holds its captured scope — usually the whole State — alive for as long as the notifier lives, which is a genuine leak. And it keeps firing after disposal, so a setState inside it runs against a disposed element.

This pairs with always_remove_listener: that rule checks that a removal exists, this one checks that the removal can actually work.

See also: Flutter: ChangeNotifier.removeListener

Rebuilding on every keystroke by subscribing with an inline closure. The dispose() below looks complete, but the removeListener cannot match a closure it never saw:

class _SearchBarState extends State<SearchBar> {
final _queryController = TextEditingController();
@override
void initState() {
super.initState();
_queryController.addListener(() => setState(() {}));
}
@override
void dispose() {
_queryController.removeListener(() => setState(() {})); // a different object
_queryController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox();
}

Give the callback a stable identity — a method reference is the same object every time it is named:

class _SearchBarState extends State<SearchBar> {
final _queryController = TextEditingController();
@override
void initState() {
super.initState();
_queryController.addListener(_onQueryChanged);
}
@override
void dispose() {
_queryController.removeListener(_onQueryChanged);
_queryController.dispose();
super.dispose();
}
void _onQueryChanged() => setState(() {});
@override
Widget build(BuildContext context) => const SizedBox();
}

A final field holding the closure works too, when the callback needs to capture something a method cannot reach:

class _RowState extends State<Row> {
late final VoidCallback _onTick = () => setState(() => _ticks++);
int _ticks = 0;
@override
void initState() {
super.initState();
widget.clock.addListener(_onTick);
}
@override
void dispose() {
widget.clock.removeListener(_onTick);
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox();
}

Matching is by method name, not by receiver type. Any single-argument call named addListener or addStatusListener that is given a closure literal is reported, whatever the receiver is. That keeps the rule working on your own Listenable implementations, but it also means an unrelated class with a method of that name is flagged — suppress those with // ignore: many_lints/avoid_unremovable_callbacks_in_listeners.

Only a closure literal written at the call site is reported. A variable, field, or method reference is assumed to have a stable identity and is left alone, even though a getter that builds a fresh closure on each read would have the same defect:

VoidCallback get _onTick => () => setState(() {}); // not reported, still unremovable

A registration with more than one argument is skipped, since this rule is about the add/remove pair specifically. Add a project wrapper with additional_methods.

This rule is in the recommended preset, so it is on with preset: recommended or preset: opinionated.

To turn it off:

many_lints.yaml
rules:
avoid_unremovable_callbacks_in_listeners: false

To keep the rule on but skip certain paths, use per-rule exclude.

analysis_options.yaml
many_lints:
rules:
avoid_unremovable_callbacks_in_listeners:
additional_methods: [addObserver]
Option Type Default Description
additional_methods list of strings [] Extra registration methods whose counterpart removes by identity