Watch the video (10 min)
On this page23 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. What Is a StreamController?
  5. 2. Adding Events with add
  6. 3. Finishing with close
  7. 4. Sending Errors with addError
  8. 5. Keep the Controller Private
  9. 6. A Class That Emits Events
  10. 7. Only One Listener
  11. 8. onListen and onCancel
  12. 9. Forgetting to close
  13. 10. Adding After close
  14. 11. A Real-World Example
  15. Code Examples
  16. Common Beginner Mistakes
  17. Best Practices
  18. Mini Challenge
  19. Practice Questions
  20. Practice Answers
  21. Quick Revision
  22. Key Takeaways
  23. What's Next?

Lecture 93 — Stream Controllers

Category15 — Asynchronous Programming
Lecture093 of 140
Estimated video length10 min
TargetDart 3.12.2
LevelIntermediate

Stream.fromIterable and Stream.periodic only replay values decided in advance. A StreamController lets your own code push each event into a stream the moment it happens.

Contents

Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways

Every output and error message in this lecture is real. Each one was produced by running the code shown on Dart 3.12.2. Stack-trace lines below an error are left out.


Prerequisites

Students should already understand, from Lectures 057–092:

  • private members with _ and public getters (Lecture 057)
  • try/catch and throwing errors (Lectures 084–085)
  • Future, async, and await (Lectures 088–089)
  • streams, await for, listen with onError and onDone, and cancelling a subscription (Lecture 092)

Connection With Previous Lectures

In Lecture 092 every stream was built from values we already knew:

dart
var marks = Stream.fromIterable([80, 95, 70]);

But a bank account does not know its next transaction in advance, and a chat room cannot list tomorrow's messages today. Those events happen when a user taps a button. We need a stream that stays open and lets our code add each event when it occurs.


Learning Objectives

By the end of this lecture you will be able to:

  • Create a StreamController<T> and read its stream
  • Send events with add, errors with addError, and finish with close
  • Keep the controller private and expose only the stream
  • Build a class that emits events, such as a bank account's transactions
  • Explain the single-subscription rule and recognize its error
  • React to listeners arriving and leaving with onListen and onCancel
  • Predict what happens when you forget to call close

1. What Is a StreamController?

A StreamController<T> is an object with two ends: the controller, where your code puts events in, and its stream, where listeners take them out.

Analogy — a letterbox in a door. You push letters through the slot on the outside (the controller); the person inside collects them from the box (the stream). The person inside never touches the slot.

StreamController lives in dart:async, so every program in this lecture starts with import 'dart:async';.


2. Adding Events with add

Create a controller, listen to its stream, and push values with add:

dart
import 'dart:async';

void main() {
  var controller = StreamController<String>();
  controller.stream.listen((message) => print('Got: $message'));
  controller.add('Hello');
  controller.add('How are you?');
  print('Messages added');
}
output
Messages added
Got: Hello
Got: How are you?

Messages added prints first. Stream events are delivered asynchronously — after the current code finishes, just like the futures in Lecture 088.


3. Finishing with close

close() tells listeners "no more events". That is what ends an await for loop:

dart
import 'dart:async';

void main() async {
  var controller = StreamController<int>();
  controller.add(80);
  controller.add(95);
  controller.close();
  await for (var marks in controller.stream) {
    print('Marks: $marks');
  }
  print('Stream closed');
}
output
Marks: 80
Marks: 95
Stream closed

The events were added before anyone listened. The controller kept them and delivered them when the await for started.


4. Sending Errors with addError

A stream can carry errors as well as values. addError sends one; the listener's onError receives it, and the stream keeps going:

dart
import 'dart:async';

void main() {
  var controller = StreamController<double>();
  controller.stream.listen(
    (temp) => print('Temperature: $temp'),
    onError: (e) => print('Sensor error: $e'),
    onDone: () => print('Sensor stopped'),
  );
  controller.add(24.5);
  controller.addError('Sensor offline');
  controller.add(25.0);
  controller.close();
}
output
Temperature: 24.5
Sensor error: Sensor offline
Temperature: 25.0
Sensor stopped

onDone runs because of close().


5. Keep the Controller Private

If other code could reach the controller, anyone could add fake events or close the stream. Use the rule from Lecture 057: make the controller private and expose only the stream through a getter.

dart
import 'dart:async';

class Doorbell {
  final _controller = StreamController<String>();

  Stream<String> get rings => _controller.stream;

  void press(String visitor) => _controller.add(visitor);
}

Outside code can listen to rings, but only press can add an event. A Stream has no add method, so the stream alone is read-only.


6. A Class That Emits Events

Now a real use. Sara's BankAccount announces every transaction on a stream, and reports a refused withdrawal as an error:

dart
import 'dart:async';

class BankAccount {
  final String owner;
  double _balance = 0;
  final _controller = StreamController<String>();

  BankAccount(this.owner);

  Stream<String> get transactions => _controller.stream;

  void deposit(double amount) {
    _balance += amount;
    _controller.add('Deposit $amount, balance $_balance');
  }

  void withdraw(double amount) {
    if (amount > _balance) {
      _controller.addError('Withdrawal of $amount refused');
      return;
    }
    _balance -= amount;
    _controller.add('Withdraw $amount, balance $_balance');
  }

  void close() => _controller.close();
}

void main() {
  var account = BankAccount('Sara');
  account.transactions.listen(
    (t) => print(t),
    onError: (e) => print('Error: $e'),
    onDone: () => print('Account closed'),
  );
  account.deposit(1000);
  account.withdraw(300);
  account.withdraw(5000);
  account.close();
}
output
Deposit 1000.0, balance 1000.0
Withdraw 300.0, balance 700.0
Error: Withdrawal of 5000.0 refused
Account closed

main never touches _controller. It calls methods and listens — the account decides what gets announced.


7. Only One Listener

A plain StreamController creates a single-subscription stream: it can be listened to once. A second listen fails:

dart
import 'dart:async';

void main() {
  var controller = StreamController<String>();
  controller.stream.listen((m) => print('Ali sees: $m'));
  controller.stream.listen((m) => print('Sara sees: $m'));
  controller.add('Hi');
}
output
Unhandled exception:
Bad state: Stream has already been listened to.

This is a protection, not a bug: each event goes to exactly one place, and none is lost before that listener arrives (Step 3). The rule belongs to the controller: a Stream.fromIterable stream from Lecture 092 can be listened to again without this error. Streams that many listeners can share are the topic of the next lecture, Broadcast Streams.


8. onListen and onCancel

A controller can run code when its listener arrives and when it leaves:

dart
import 'dart:async';

void main() async {
  var controller = StreamController<int>(
    onListen: () => print('Someone is listening'),
    onCancel: () => print('Listener left'),
  );
  print('Before listen');
  var subscription = controller.stream.listen((n) => print('Score: $n'));
  controller.add(10);
  controller.add(20);
  await Future.delayed(Duration.zero);
  await subscription.cancel();
  print('After cancel');
}
output
Before listen
Someone is listening
Score: 10
Score: 20
Listener left
After cancel

Use onListen to start expensive work only when someone cares — opening a sensor, a connection — and onCancel to stop it. Duration.zero gives the events a turn to be delivered before we cancel.


9. Forgetting to close

What if nobody calls close()?

dart
import 'dart:async';

void main() async {
  var controller = StreamController<String>();
  controller.add('Bread');
  controller.add('Milk');
  await for (var item in controller.stream) {
    print('Item: $item');
  }
  print('Shopping done');
}
output
Item: Bread
Item: Milk

Shopping done never prints — and there is no error. The await for keeps waiting for an event that will never come. Here the program simply exits, because nothing else is left for the event loop (Lecture 087) to do. In a running app, that loop would wait forever and onDone would never fire. Always close a controller when its events are finished.


10. Adding After close

The opposite mistake — adding once the controller is closed — fails loudly:

dart
import 'dart:async';

void main() {
  var controller = StreamController<int>();
  controller.stream.listen(print);
  controller.add(1);
  controller.close();
  controller.add(2);
}
output
Unhandled exception:
Bad state: Cannot add event after closing

11. A Real-World Example

A chat room with a private controller, a public stream, errors for empty messages, and onListen/onCancel:

dart
import 'dart:async';

class ChatRoom {
  final _controller = StreamController<String>(
    onListen: () => print('[reader joined]'),
    onCancel: () => print('[reader left]'),
  );

  Stream<String> get messages => _controller.stream;

  void send(String sender, String text) {
    if (text.isEmpty) {
      _controller.addError('$sender sent an empty message');
      return;
    }
    _controller.add('$sender: $text');
  }

  Future<void> close() => _controller.close();
}

void main() {
  var room = ChatRoom();
  room.messages.listen(
    (message) => print(message),
    onError: (e) => print('Error: $e'),
    onDone: () => print('Chat ended'),
  );
  room.send('Ali', 'Hi everyone');
  room.send('Sara', '');
  room.send('Sara', 'Hello Ali');
  room.send('Zayan', 'Can I join?');
  room.close();
}
output
[reader joined]
Ali: Hi everyone
Error: Sara sent an empty message
Sara: Hello Ali
Zayan: Can I join?
[reader left]
Chat ended

close() returns the controller's Future<void>, which completes once the stream is done.


Code Examples

Every example starts with import 'dart:async';.

Example 1 — Add and Listen

dart
import 'dart:async';

void main() {
  var controller = StreamController<String>();
  controller.stream.listen((name) => print('Welcome, $name'));
  controller.add('Ali');
  controller.add('Sara');
}
output
Welcome, Ali
Welcome, Sara

Example 2 — close Triggers onDone

dart
import 'dart:async';

void main() {
  var controller = StreamController<int>();
  controller.stream.listen(
    (n) => print('Number: $n'),
    onDone: () => print('No more numbers'),
  );
  controller.add(1);
  controller.add(2);
  controller.close();
}
output
Number: 1
Number: 2
No more numbers

Example 3 — await for over a Controller

dart
import 'dart:async';

void main() async {
  var controller = StreamController<String>();
  for (var day in ['Mon', 'Tue', 'Wed']) {
    controller.add(day);
  }
  controller.close();
  await for (var day in controller.stream) {
    print(day);
  }
}
output
Mon
Tue
Wed

Example 4 — addError

dart
import 'dart:async';

void main() {
  var controller = StreamController<int>();
  controller.stream.listen(
    (age) => print('Age: $age'),
    onError: (e) => print('Problem: $e'),
  );
  controller.add(20);
  controller.addError('Age cannot be negative');
  controller.add(21);
}
output
Age: 20
Problem: Age cannot be negative
Age: 21

Example 5 — Private Controller, Public Stream

dart
import 'dart:async';

class Doorbell {
  final _controller = StreamController<String>();

  Stream<String> get rings => _controller.stream;

  void press(String visitor) => _controller.add(visitor);
}

void main() {
  var bell = Doorbell();
  bell.rings.listen((visitor) => print('Ding dong: $visitor'));
  bell.press('Zayan');
}
output
Ding dong: Zayan

Example 6 — onListen Starts the Work

dart
import 'dart:async';

void main() {
  late StreamController<int> controller;
  controller = StreamController<int>(
    onListen: () {
      print('Listener arrived, sending data');
      controller.add(42);
      controller.close();
    },
  );
  print('Nothing sent yet');
  controller.stream.listen(print);
}
output
Nothing sent yet
Listener arrived, sending data
42

onListen uses controller, so it is declared late first (Lecture 006).

Example 7 — Student Marks

dart
import 'dart:async';

class MarksBoard {
  final _controller = StreamController<int>();

  Stream<int> get marks => _controller.stream;

  void record(int mark) => _controller.add(mark);

  void finish() => _controller.close();
}

void main() async {
  var board = MarksBoard();
  board.record(80);
  board.record(75);
  board.record(85);
  board.finish();

  var total = 0;
  await for (var mark in board.marks) {
    total += mark;
  }
  print('Total: $total');
}
output
Total: 240

How it works — finish() closes the controller, so the await for ends and the total prints. Without it, Total would never appear.

Example 8 — Shopping Total

dart
import 'dart:async';

class Cart {
  final _controller = StreamController<double>();

  Stream<double> get prices => _controller.stream;

  void addItem(double price) => _controller.add(price);

  void checkout() => _controller.close();
}

void main() {
  var cart = Cart();
  var total = 0.0;
  cart.prices.listen((price) {
    total += price;
    print('Added $price, total $total');
  }, onDone: () => print('Pay: $total'));
  cart.addItem(500);
  cart.addItem(1200);
  cart.checkout();
}
output
Added 500.0, total 500.0
Added 1200.0, total 1700.0
Pay: 1700.0

Example 9 — Bank Balance

dart
import 'dart:async';

class Wallet {
  double _balance = 200;
  final _controller = StreamController<double>();

  Stream<double> get balances => _controller.stream;

  void spend(double amount) {
    if (amount > _balance) {
      _controller.addError('Not enough money for $amount');
    } else {
      _balance -= amount;
      _controller.add(_balance);
    }
  }
}

void main() {
  var wallet = Wallet();
  wallet.balances.listen(
    (b) => print('Balance: $b'),
    onError: (e) => print('Error: $e'),
  );
  wallet.spend(50);
  wallet.spend(500);
  wallet.spend(100);
}
output
Balance: 150.0
Error: Not enough money for 500.0
Balance: 50.0

Example 10 — Temperature Sensor

dart
import 'dart:async';

class Thermometer {
  final _controller = StreamController<double>(
    onListen: () => print('Sensor on'),
    onCancel: () => print('Sensor off'),
  );

  Stream<double> get readings => _controller.stream;

  void read(double celsius) => _controller.add(celsius);

  void stop() => _controller.close();
}

void main() async {
  var sensor = Thermometer();
  sensor.read(22.5);
  sensor.read(23.0);
  sensor.stop();
  await for (var c in sensor.readings) {
    print('$c°C = ${c * 9 / 5 + 32}°F');
  }
}
output
Sensor on
22.5°C = 72.5°F
23.0°C = 73.4°F
Sensor off

Common Beginner Mistakes

1. Listening twice to a controller's stream

dart
import 'dart:async';

void main() {
  var controller = StreamController<int>();
  controller.stream.listen(print);
  // controller.stream.listen(print); // ✗
}
output
Bad state: Stream has already been listened to.

One listener per plain controller. Broadcast streams (Lecture 094) allow more.

2. Forgetting close

No error appears — await for never ends and onDone never runs (Step 9). Close the controller when its events are finished.

3. Adding after close

dart
import 'dart:async';

void main() {
  var controller = StreamController<int>();
  controller.close();
  // controller.add(1); // ✗
}
output
Bad state: Cannot add event after closing

4. Making the controller public

dart
import 'dart:async';

class BankAccount {
  final controller = StreamController<String>(); // ✗ anyone can add or close
}

Name it _controller and expose Stream<String> get transactions.

5. Forgetting the import

dart
// import 'dart:async'; // missing
void main() {
  // var controller = StreamController<int>(); // ✗
}
output
Error: Method not found: 'StreamController'.

Best Practices

  • Keep the controller private (_controller) and expose only a Stream getter.
  • Give the controller a type: StreamController<double>, not a bare StreamController.
  • Report problems with addError instead of throwing from inside the class.
  • Always close a controller when no more events will come.
  • Use onListen to start expensive work and onCancel to stop it.
  • Never add after closing; a close() method on your class makes the end explicit.

Mini Challenge

Zayan's game has a Scoreboard class. It keeps a private controller of int, exposes a points stream, has score(int p) to add points and end() to close. Listen, keep a running total, and print the final total when the stream is done.

output
+10 (total 10)
+25 (total 35)
+5 (total 40)
Final score: 40

Try it yourself before opening the solution.

Show solution
dart
import 'dart:async';

class Scoreboard {
  final _controller = StreamController<int>();

  Stream<int> get points => _controller.stream;

  void score(int p) => _controller.add(p);

  void end() => _controller.close();
}

void main() {
  var board = Scoreboard();
  var total = 0;
  board.points.listen((p) {
    total += p;
    print('+$p (total $total)');
  }, onDone: () => print('Final score: $total'));
  board.score(10);
  board.score(25);
  board.score(5);
  board.end();
}
output
+10 (total 10)
+25 (total 35)
+5 (total 40)
Final score: 40

How it works — end() closes the controller, which triggers onDone.


Practice Questions

Solve each one before looking at the answers.

Beginner

  1. Create a StreamController<String>, listen to it, and add 'Karachi' and 'Lahore'.
  2. Add the numbers 1, 2, 3 to a controller, close it, and print them with await for.
  3. Listen with onDone so that Finished prints after the events 'A' and 'B'.

Application

  1. Send 90, then the error 'Missing mark', then 70. Print values and the error with onError.

  2. Write a Counter class with a private controller, a counts stream, and an increment() method that adds the new count. Increment three times.

  3. Give a controller onListen and onCancel callbacks. Listen, add 5, then close it. What prints?

  4. Predict the output, then explain why the last line never prints:

    dart
    import 'dart:async';
    
    void main() async {
      var c = StreamController<int>();
      c.add(1);
      await for (var n in c.stream) {
        print(n);
      }
      print('End');
    }
  5. What happens when two listeners call listen on the same plain controller's stream?

Challenge

  1. Write a Thermostat class whose set(double c) adds c if it is between 10 and 30, and otherwise adds the error '$c is out of range'. Set 21.0, 35.0, 18.5.
  2. Write a ShoppingList class with add(String item) and done(). In main, collect every item with await for into a list and print Items: [Bread, Milk, Eggs].

Practice Answers

Question 1 — Solution

dart
import 'dart:async';

void main() {
  var controller = StreamController<String>();
  controller.stream.listen(print);
  controller.add('Karachi');
  controller.add('Lahore');
}
output
Karachi
Lahore

Question 2 — Solution

dart
import 'dart:async';

void main() async {
  var controller = StreamController<int>();
  controller.add(1);
  controller.add(2);
  controller.add(3);
  controller.close();
  await for (var n in controller.stream) {
    print(n);
  }
}
output
1
2
3

Question 3 — Solution

dart
import 'dart:async';

void main() {
  var controller = StreamController<String>();
  controller.stream.listen(print, onDone: () => print('Finished'));
  controller.add('A');
  controller.add('B');
  controller.close();
}
output
A
B
Finished

Question 4 — Solution

dart
import 'dart:async';

void main() {
  var controller = StreamController<int>();
  controller.stream.listen(
    (m) => print('Mark: $m'),
    onError: (e) => print('Error: $e'),
  );
  controller.add(90);
  controller.addError('Missing mark');
  controller.add(70);
}
output
Mark: 90
Error: Missing mark
Mark: 70

Question 5 — Solution

dart
import 'dart:async';

class Counter {
  int _count = 0;
  final _controller = StreamController<int>();

  Stream<int> get counts => _controller.stream;

  void increment() {
    _count++;
    _controller.add(_count);
  }
}

void main() {
  var counter = Counter();
  counter.counts.listen((n) => print('Count: $n'));
  counter.increment();
  counter.increment();
  counter.increment();
}
output
Count: 1
Count: 2
Count: 3

Question 6 — Solution

dart
import 'dart:async';

void main() {
  var controller = StreamController<int>(
    onListen: () => print('Listening'),
    onCancel: () => print('Cancelled'),
  );
  controller.stream.listen(print);
  controller.add(5);
  controller.close();
}
output
Listening
5
Cancelled

When the stream closes, the subscription ends, so onCancel runs too.

Question 7 — Solution

output
1

The controller is never closed, so await for keeps waiting for a second event. With nothing left to do, the program exits without printing End. Adding c.close(); after c.add(1); makes End print.

Question 8 — Solution

The second listen throws Bad state: Stream has already been listened to. A plain controller's stream is single-subscription.

Question 9 — Solution

dart
import 'dart:async';

class Thermostat {
  final _controller = StreamController<double>();

  Stream<double> get temperatures => _controller.stream;

  void set(double c) {
    if (c >= 10 && c <= 30) {
      _controller.add(c);
    } else {
      _controller.addError('$c is out of range');
    }
  }
}

void main() {
  var t = Thermostat();
  t.temperatures.listen(
    (c) => print('Set to $c'),
    onError: (e) => print('Error: $e'),
  );
  t.set(21.0);
  t.set(35.0);
  t.set(18.5);
}
output
Set to 21.0
Error: 35.0 is out of range
Set to 18.5

Question 10 — Solution

dart
import 'dart:async';

class ShoppingList {
  final _controller = StreamController<String>();

  Stream<String> get items => _controller.stream;

  void add(String item) => _controller.add(item);

  void done() => _controller.close();
}

void main() async {
  var list = ShoppingList();
  list.add('Bread');
  list.add('Milk');
  list.add('Eggs');
  list.done();

  var collected = <String>[];
  await for (var item in list.items) {
    collected.add(item);
  }
  print('Items: $collected');
}
output
Items: [Bread, Milk, Eggs]

Quick Revision

  1. Which library do you import to use StreamController?
  2. What are the two ends of a controller?
  3. Which methods send a value, send an error, and finish the stream?
  4. Why keep the controller private?
  5. What error does a second listen give?
  6. When do onListen and onCancel run?
  7. What happens to an await for loop if the controller is never closed?

Key Takeaways

  • A StreamController<T> (from dart:async) lets your code push events into a stream.
  • add sends a value, addError an error, and close ends the stream.
  • Events arrive asynchronously; events added before a listener arrives are kept.
  • Keep the controller private and expose a Stream getter, so only the class adds events.
  • A plain controller's stream allows one listener; a second throws a Bad state error.
  • onListen and onCancel run when the listener arrives and leaves.
  • Forgetting close gives no error — await for and onDone simply never finish.
  • Adding after close throws Cannot add event after closing.

What's Next?

Lecture 094 — Broadcast Streams. Our chat room had one reader, and a second listen crashed. A real chat room has many readers, and a bank account may update a balance screen and a history screen at once. Next we will create broadcast streams that any number of listeners can share.


Video lecture

Subscribe
Placeholder video — the real recording isn't linked yet.