In a Flutter app, what goes wrong when an RxDart subject owned by a screen's controller is never closed, and what does close() change?
answer
- done event ends every listener
- add after close: StateError
- cached value survives close
- addStream blocks close
- close_sinks is opt-in
basics
~20 sAn unclosed subject keeps its listeners, their captured objects and any upstream feed alive while something long-lived references it, and listeners waiting for done never finish. close() sends done to every listener and makes later add or addError throw StateError.
solid answer
~40 s`close()` closes the subject's broadcast controller: every current listener gets a done event, `isClosed` becomes true, and any later `add` or `addError` throws `StateError` — 'Cannot add new events after calling close'. The cached state is kept, so a `BehaviorSubject`'s `value` and a `ReplaySubject`'s `values` are still readable, and calling `close()` twice is harmless. If a controller never closes its subject, the damage depends on what still references it: an upstream subscription feeding it keeps the source running, listener closures keep screen objects reachable, `await for` loops never end, and an unbounded `ReplaySubject` keeps growing. So in `dispose` I cancel the upstream subscription first, then close the subject, and guard late async callbacks with `isClosed`.
code
dart · 16 linesimport 'package:rxdart/rxdart.dart';
Future<void> main() async {
final subject = BehaviorSubject<int>.seeded(1);
subject.listen(print, onDone: () => print('done'));
await subject.close(); // prints 1, then done
await subject.close(); // harmless: same done future
print(subject.value); // 1 - the cached value survives
try {
subject.add(2);
} on StateError catch (e) {
print(e.message); // Cannot add new events after calling close
}
}go deeper
Remember to close every subject you create in the owner's dispose, and that adding after close throws StateError.
Explain what close delivers to listeners, that the cached value survives, and the cancel-then-close order for a subject fed by another stream.
Diagnose real leaks by what still references the subject, spot the addStream-blocks-close trap, and guard async callbacks that complete after dispose.
Decide how the team enforces disposal: an opt-in lint with known gaps, ownership conventions in review, or moving lifecycles into a library that disposes automatically.
## What close() does on an RxDart subject Every rxdart subject wraps a broadcast `StreamController`, and `Subject.close()` closes it. Concretely: - Every **current listener** receives a done event, so their `onDone` callbacks run and their subscriptions end. - `isClosed` becomes `true`, and the returned `Future` completes when the done event has been delivered. - Any later **`add` or `addError` throws `StateError`** with the message "Cannot add new events after calling close". - The **cached state survives**: a `BehaviorSubject` still returns its last `value`, and a `ReplaySubject` still returns its buffered `values`. The subject stops accepting events; it does not forget them. - Calling `close()` a second time is harmless; the broadcast controller returns the same done future. ## What an unclosed subject actually costs A subject that nothing references is garbage-collected like any other object, closed or not. The damage happens when something **long-lived still reaches it**: | Leftover | Effect | |---|---| | An upstream `listen(_subject.add)` subscription that is never cancelled | the source — a location feed, a socket, a timer — keeps running and keeps the subject reachable | | Listeners that captured a screen's objects | those objects stay reachable after the screen is gone | | `await for`, `toList()` or `.last` waiting on the subject | they wait forever, because no done event arrives | | A `ReplaySubject` without `maxSize` | its buffer grows for as long as the subject lives | The typical Flutter shape is a controller or view model owned by a screen, holding a subject that a singleton service feeds. The screen closes, but the service's subscription still points at the subject, so everything the subject's listeners captured stays alive. ## Closing in the right order In the owner's teardown: 1. **Cancel the upstream subscriptions** that feed the subject, so nothing tries to `add` after the close. 2. **Close the subject** (and every other subject the owner created). 3. Let the rest of the owner's teardown run — for a `State`, the framework's own `dispose` ordering applies. If you close first and cancel second, an event already in flight can hit `add` on a closed subject and throw. ## The addStream trap `Subject.addStream(source)` pipes another stream into the subject, but **while it is active** the subject throws `StateError` on `add`, `addError`, a second `addStream` — and on **`close()`**. With a finite source that is fine: the returned `Future` completes and the subject is usable again. With an endless source such as a sensor feed, `close()` in `dispose` throws. Prefer `listen` with a stored `StreamSubscription` for long-lived sources. ## Late async callbacks A network call started by the screen may complete after `dispose`. Its callback then calls `_subject.add(result)` on a closed subject and throws `StateError`. Options: - cancel the operation's subscription in `dispose`, which is the cleanest fix; - or guard with `if (!_subject.isClosed) _subject.add(result);` where cancellation is not possible. ## Tooling that helps — and its limits The Dart linter rule **`close_sinks`** flags `Sink` instances (a subject is one) that are never closed. It is a stable rule but belongs to **no predefined set**: neither `package:lints` core or recommended nor `flutter_lints` enables it, so you add it to `analysis_options.yaml` yourself. Its documentation lists known limitations — it does not track every pattern — which is why rxdart's own sources carry `// ignore: close_sinks` comments where ownership is clear. Treat it as a hint, and find real retention with a memory profiler. ```dart import 'dart:async'; import 'package:rxdart/rxdart.dart'; class SearchController { SearchController(Stream<List<String>> results) { _sub = results.listen(_items.add, onError: _items.addError); } final _items = BehaviorSubject<List<String>>.seeded(const []); late final StreamSubscription<List<String>> _sub; ValueStream<List<String>> get items => _items.stream; Future<void> dispose() async { await _sub.cancel(); // 1: stop the feed await _items.close(); // 2: done to listeners; add() now throws } } ```
- Why can close() throw StateError even on a subject that is still open?Because an `addStream` is in progress. While it runs, rxdart's `Subject` rejects `add`, `addError`, another `addStream` and `close` with a `StateError`. With an endless source that state never ends, so feed long-lived sources with `listen` and a cancellable subscription instead.
- Does the close_sinks lint catch an unclosed BehaviorSubject for you by default?No. `close_sinks` is a stable Dart linter rule, but it is not in any predefined set: `package:lints` and `flutter_lints` leave it off, so you must enable it in `analysis_options.yaml`. Even then its documented limitations mean it misses some patterns, so it is a hint rather than a guarantee.
saying these in an interview costs you the question
- Every subject you forget to close leaks, even when nothing references it.
- After close(), a BehaviorSubject's value is cleared and reading it throws.
- Adding to a closed subject is silently ignored.
- Calling close() twice on a subject throws.
- Close the subject first, then cancel the subscription that feeds it.
- flutter_lints enables close_sinks, so unclosed subjects are always reported.