skip to content

Subjects & ValueStream

Subjects are broadcast stream controllers with memory: BehaviorSubject replays its latest value, ReplaySubject a buffer, PublishSubject nothing. Interviewers ask which suits UI state and why.

on this pageshow

explore

questions

4

In RxDart, what does a new listener receive from a BehaviorSubject, a PublishSubject and a ReplaySubject, and which suits UI state?

level: juniorimportance: must knowfreq 55%

answer

  1. memory at subscribe time
  2. latest one vs none vs a buffer
  3. all three wrap a broadcast controller
  4. seeded gives a value before any add
  5. maxSize null means unbounded

basics

~20 s

A late BehaviorSubject listener first gets the latest value (or error), a PublishSubject listener gets only later events, and a ReplaySubject listener gets the buffered history, capped by maxSize. BehaviorSubject suits UI state: late listeners see the current value.

solid answer

~40 s

All three rxdart subjects are broadcast `StreamController`s that are also streams; they differ in what a listener that arrives late receives. `BehaviorSubject` caches one item and replays the latest value (or the latest error, if that came last) before live events, and `BehaviorSubject.seeded(x)` provides a value before anything is added. `PublishSubject` replays nothing, so a late listener only sees future events. `ReplaySubject` replays a queue of past events, which is unbounded unless you pass `maxSize`. For UI state I pick `BehaviorSubject`: a widget that subscribes after navigation gets the current state at once, and code can read it synchronously through `value`/`valueOrNull`. `PublishSubject` is for one-off events such as a snackbar trigger.

code

dart · 20 lines
dart
import 'package:rxdart/rxdart.dart';

typedef Position = ({double lat, double lng});

Future<void> main() async {
  final location = BehaviorSubject<Position>();
  final permissionDenied = PublishSubject<void>();

  location.add((lat: 52.52, lng: 13.40));
  location.add((lat: 52.53, lng: 13.41));
  permissionDenied.add(null); // dropped: nobody is listening yet

  // A screen that opens later still sees the last fix.
  location.listen((p) => print('map centred on $p'));
  permissionDenied.listen((_) => print('show permission banner'));

  await Future<void>.delayed(Duration.zero);
  await location.close();
  await permissionDenied.close();
}

go deeper

for a junior

Recall one line per subject: Behavior replays the latest, Publish replays nothing, Replay replays a buffer. Then name BehaviorSubject for screen state and say why.

for a middle

Explain seeded versus unseeded, that all three are broadcast, that errors can be replayed, and that ReplaySubject's maxSize defaults to unbounded.

for a senior

Show judgement about event versus state: one-off navigation or snackbar events belong on a PublishSubject so they are not replayed, and an unbounded ReplaySubject on a long-lived source is a memory risk.

for a principal

Discuss when a raw subject is still the right tool in a codebase that has adopted BLoC or Riverpod, and how to keep subjects private behind read-only streams across team boundaries.

## What a subject is in RxDart A **subject** in the `rxdart` package (0.28.0) is a `StreamController` that is also a `Stream`: you push events with `add`, `addError` and `close`, and anyone can `listen` to it directly or to its `stream` getter. All three built-in subjects — `BehaviorSubject`, `PublishSubject` and `ReplaySubject` — wrap a **broadcast** controller from `dart:async`, so several listeners can subscribe at the same time. What separates them is **memory**: what a listener that subscribes *after* some events were added receives first. - Each factory takes optional `onListen` and `onCancel` callbacks and a `sync` flag that defaults to `false`. - Each is both the sink you write to and the source you listen to, which is why a service normally keeps the subject private and exposes only its stream. ## The three subjects side by side | Subject | What a new listener receives first | Synchronous read | Typical use | |---|---|---|---| | `BehaviorSubject` | the latest value, or the latest error if that came last | `value`, `valueOrNull`, `hasValue` | current state | | `PublishSubject` | nothing — only events added after it subscribed | none | one-off events | | `ReplaySubject` | the buffered events, oldest first, up to `maxSize` | `values`, `errors` | a short history | ## BehaviorSubject: the latest value, optionally seeded `BehaviorSubject` keeps one slot. Every `add` overwrites it; a new listener receives the cached item first and then every later event. `BehaviorSubject.seeded(x)` fills the slot at construction, so the subject has a current value before anything is added. Because it implements **`ValueStream`**, the slot can also be read synchronously with `value`, `valueOrNull` and `hasValue`. - If the last event added was an **error**, a new listener receives that error instead of a value. - The next `add` restores normal replay of the latest value. ## PublishSubject: only what happens from now on `PublishSubject` is a broadcast controller that is also a stream, with no memory at all. Events added while nobody listens are dropped, and a listener that arrives late has missed them for good. That is the right shape for **events that should fire once**: show a snackbar, navigate to a result screen, report that location permission was denied. Replaying those to a screen that subscribes later would repeat an action the user has already seen. ## ReplaySubject and maxSize `ReplaySubject` keeps a queue of past events — data *and* errors — and replays them in order to each new listener before live events. 1. `maxSize` is **nullable and null by default, which means unbounded**: a replay subject fed by a long-lived source keeps growing for as long as it lives. 2. With `maxSize: 2`, adding 1, 2 and 3 drops 1, and each new listener receives 2 then 3. 3. Errors occupy slots in the same queue, so they count towards `maxSize` too. 4. The buffered data is readable through `values` and the buffered errors through `errors`. ## Which one for UI state For state a screen renders — the device's current position, a cart total, an active filter — interviewers expect **`BehaviorSubject`**: - A widget that subscribes late (after a route push, or after a rebuild swapped its stream) still gets the current value without waiting for the next change. - Code outside the widget tree can read the current state synchronously. - It stores one item, so its memory does not grow with the number of updates. `PublishSubject` would leave a late screen blank until the next update arrives, and `ReplaySubject` without `maxSize` would replay — and retain — every past state. `ReplaySubject(maxSize: 1)` replays like a behaviour subject but offers `values` (a list) instead of the single-value `ValueStream` getters, and cannot be seeded. ```dart import 'package:rxdart/rxdart.dart'; Future<void> main() async { final behavior = BehaviorSubject<int>(); final publish = PublishSubject<int>(); final replay = ReplaySubject<int>(maxSize: 2); for (final subject in [behavior, publish, replay]) { subject..add(1)..add(2)..add(3); } behavior.listen((v) => print('behavior $v')); // behavior 3 publish.listen((v) => print('publish $v')); // nothing replay.listen((v) => print('replay $v')); // replay 2, replay 3 await Future<void>.delayed(Duration.zero); await Future.wait([behavior.close(), publish.close(), replay.close()]); } ``` The one-line version for an interview: **Behavior remembers the last, Publish remembers nothing, Replay remembers a buffer** — and UI state wants the last.

  • What does BehaviorSubject.seeded add over the plain BehaviorSubject constructor?
    `BehaviorSubject.seeded(x)` stores `x` as the current value at construction, so `hasValue` is already true, `value` does not throw, and the first listener receives `x` even though nothing was added. Use it when a real initial state exists, such as an empty list or a zero total; do not invent a fake seed for data that genuinely has no value yet.
  • Does a BehaviorSubject replay errors to new listeners?
    Yes, when the last event was an error: a new listener receives that error first. As soon as a later `add` arrives, replay switches back to the latest value. A `ReplaySubject` also replays errors, in their position in its buffer.

A BehaviorSubject is a departures board: whoever walks in sees the current status at once. A PublishSubject is a station announcement: miss it and it is gone. A ReplaySubject is a recording of the last few announcements, played to each newcomer.

saying these in an interview costs you the question

  • PublishSubject replays the last value to listeners that subscribe late.
  • BehaviorSubject is single-subscription, so only one widget can listen to it.
  • ReplaySubject keeps only a small buffer by default.
  • BehaviorSubject never replays an error, only data values.
  • A plain broadcast StreamController already replays its latest value.
open as a page

How would you build a Flutter current-location service on an RxDart BehaviorSubject so screens opened later see the last position?

level: middleimportance: should knowfreq 32%

basics

~10 s

Keep a private unseeded BehaviorSubject fed from the platform location stream, expose its read-only ValueStream plus valueOrNull, and close it with the upstream subscription. Screens opened later receive the last position on subscribe.

open as a page

In RxDart, what do value, valueOrNull and hasValue return on a BehaviorSubject's ValueStream, and when does value throw?

level: middleimportance: should knowfreq 38%

basics

~20 s

value returns the last emitted value and throws ValueStreamError when there is none; valueOrNull returns it or null; hasValue says whether any value, including a null seed, was emitted. Only an unseeded, never-added BehaviorSubject makes value throw.

open as a page

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?

level: seniorimportance: should knowfreq 34%

basics

~20 s

An 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.

open as a page