Skip to content

avoid_unassigned_stream_subscriptions

v0.4.0WarningConfigurableResource Management

Flags Stream.listen() calls whose return value (a StreamSubscription) is not assigned to a variable, returned, or passed as an argument. Without storing the subscription, you have no way to cancel it later, which leads to memory leaks and unexpected behavior.

A StreamSubscription that is never stored cannot be cancelled. The listener keeps running indefinitely, holding references to the callback closure and everything it captures. This is especially problematic in StatefulWidgets where the stream may outlive the widget, causing setState() calls on a disposed State.

See also: Dart - Streams | StreamSubscription | Dart lint: cancel_subscriptions

Subscribing in initState and dropping the handle. The widget is disposed, the stream keeps producing, and the callback keeps calling setState on a dead State:

import 'dart:async';
class _ChatState extends State<Chat> {
final Stream<String> _messages = const Stream<String>.empty();
List<String> _received = [];
@override
void initState() {
super.initState();
// Nothing holds the subscription, so dispose() has nothing to cancel
_messages.listen((message) {
setState(() => _received = [..._received, message]);
});
}
@override
Widget build(BuildContext context) => const SizedBox();
}

Store the handle in a field and cancel it in dispose():

import 'dart:async';
class _ChatState extends State<Chat> {
final Stream<String> _messages = const Stream<String>.empty();
List<String> _received = [];
StreamSubscription<String>? _subscription;
@override
void initState() {
super.initState();
_subscription = _messages.listen((message) {
setState(() => _received = [..._received, message]);
});
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox();
}

A method that subscribes on someone else’s behalf satisfies the rule by returning the handle — the caller then owns cancellation:

import 'dart:async';
StreamSubscription<String> watchMessages(
Stream<String> messages,
void Function(String) onMessage,
) {
return messages.listen(onMessage);
}

Passing the handle as an argument counts too, which is the usual shape when one object owns a bag of subscriptions:

import 'dart:async';
class _FeedState extends State<Feed> {
final Stream<String> _likes = const Stream<String>.empty();
final Stream<String> _comments = const Stream<String>.empty();
final _subscriptions = <StreamSubscription<String>>[];
@override
void initState() {
super.initState();
_subscriptions.add(_likes.listen(_onLike));
_subscriptions.add(_comments.listen(_onComment));
}
void _onLike(String id) {}
void _onComment(String id) {}
@override
void dispose() {
for (final subscription in _subscriptions) {
subscription.cancel();
}
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox();
}

The rule reports a listen(...) that stands alone as a statement. Anything that consumes the returned subscription — assigning it, returning it, passing it as an argument, awaiting it — satisfies the rule.

It checks only that the handle goes somewhere, not that it is ever cancelled. final subscription = stream.listen(...); with no matching cancel() passes this rule; the cancellation itself is dispose_fields’s job once the handle is a State field.

The receiver must resolve to something whose listen returns a dart:async StreamSubscription, so an unrelated listen method of your own is never flagged.

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

To turn it off:

many_lints.yaml
rules:
avoid_unassigned_stream_subscriptions: false

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

analysis_options.yaml
many_lints:
rules:
avoid_unassigned_stream_subscriptions:
ignored_instances: [eventBus]
Option Type Default Description
ignored_instances list of strings [] Receiver expressions whose subscriptions are torn down centrally