On this page23 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. What Is a StreamController?
- 2. Adding Events with add
- 3. Finishing with close
- 4. Sending Errors with addError
- 5. Keep the Controller Private
- 6. A Class That Emits Events
- 7. Only One Listener
- 8. onListen and onCancel
- 9. Forgetting to close
- 10. Adding After close
- 11. A Real-World Example
- Code Examples
- Common Beginner Mistakes
- Best Practices
- Mini Challenge
- Practice Questions
- Practice Answers
- Quick Revision
- Key Takeaways
- What's Next?
Lecture 93 — Stream Controllers
| Category | 15 — Asynchronous Programming |
| Lecture | 093 of 140 |
| Estimated video length | 10 min |
| Target | Dart 3.12.2 |
| Level | Intermediate |
Stream.fromIterableandStream.periodiconly replay values decided in advance. AStreamControllerlets 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/catchand throwing errors (Lectures 084–085)Future,async, andawait(Lectures 088–089)- streams,
await for,listenwithonErrorandonDone, and cancelling a subscription (Lecture 092)
Connection With Previous Lectures
In Lecture 092 every stream was built from values we already knew:
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 itsstream - Send events with
add, errors withaddError, and finish withclose - 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
onListenandonCancel - 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:
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');
}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:
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');
}Marks: 80
Marks: 95
Stream closedThe 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:
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();
}Temperature: 24.5
Sensor error: Sensor offline
Temperature: 25.0
Sensor stoppedonDone 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.
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:
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();
}Deposit 1000.0, balance 1000.0
Withdraw 300.0, balance 700.0
Error: Withdrawal of 5000.0 refused
Account closedmain 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:
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');
}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:
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');
}Before listen
Someone is listening
Score: 10
Score: 20
Listener left
After cancelUse 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()?
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');
}Item: Bread
Item: MilkShopping 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:
import 'dart:async';
void main() {
var controller = StreamController<int>();
controller.stream.listen(print);
controller.add(1);
controller.close();
controller.add(2);
}Unhandled exception:
Bad state: Cannot add event after closing11. A Real-World Example
A chat room with a private controller, a public stream, errors for empty messages, and
onListen/onCancel:
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();
}[reader joined]
Ali: Hi everyone
Error: Sara sent an empty message
Sara: Hello Ali
Zayan: Can I join?
[reader left]
Chat endedclose() 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
import 'dart:async';
void main() {
var controller = StreamController<String>();
controller.stream.listen((name) => print('Welcome, $name'));
controller.add('Ali');
controller.add('Sara');
}Welcome, Ali
Welcome, SaraExample 2 — close Triggers onDone
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();
}Number: 1
Number: 2
No more numbersExample 3 — await for over a Controller
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);
}
}Mon
Tue
WedExample 4 — addError
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);
}Age: 20
Problem: Age cannot be negative
Age: 21Example 5 — Private Controller, Public Stream
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');
}Ding dong: ZayanExample 6 — onListen Starts the Work
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);
}Nothing sent yet
Listener arrived, sending data
42onListen uses controller, so it is declared late first (Lecture 006).
Example 7 — Student Marks
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');
}Total: 240How 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
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();
}Added 500.0, total 500.0
Added 1200.0, total 1700.0
Pay: 1700.0Example 9 — Bank Balance
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);
}Balance: 150.0
Error: Not enough money for 500.0
Balance: 50.0Example 10 — Temperature Sensor
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');
}
}Sensor on
22.5°C = 72.5°F
23.0°C = 73.4°F
Sensor offCommon Beginner Mistakes
1. Listening twice to a controller's stream
import 'dart:async';
void main() {
var controller = StreamController<int>();
controller.stream.listen(print);
// controller.stream.listen(print); // ✗
}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
import 'dart:async';
void main() {
var controller = StreamController<int>();
controller.close();
// controller.add(1); // ✗
}Bad state: Cannot add event after closing4. Making the controller public
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
// import 'dart:async'; // missing
void main() {
// var controller = StreamController<int>(); // ✗
}Error: Method not found: 'StreamController'.Best Practices
- Keep the controller private (
_controller) and expose only aStreamgetter. - Give the controller a type:
StreamController<double>, not a bareStreamController. - Report problems with
addErrorinstead of throwing from inside the class. - Always close a controller when no more events will come.
- Use
onListento start expensive work andonCancelto 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.
+10 (total 10)
+25 (total 35)
+5 (total 40)
Final score: 40Try it yourself before opening the solution.
Show solution
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();
}+10 (total 10)
+25 (total 35)
+5 (total 40)
Final score: 40How it works — end() closes the controller, which triggers onDone.
Practice Questions
Solve each one before looking at the answers.
Beginner
- Create a
StreamController<String>, listen to it, and add'Karachi'and'Lahore'. - Add the numbers
1,2,3to a controller, close it, and print them withawait for. - Listen with
onDoneso thatFinishedprints after the events'A'and'B'.
Application
-
Send
90, then the error'Missing mark', then70. Print values and the error withonError. -
Write a
Counterclass with a private controller, acountsstream, and anincrement()method that adds the new count. Increment three times. -
Give a controller
onListenandonCancelcallbacks. Listen, add5, then close it. What prints? -
Predict the output, then explain why the last line never prints:
import 'dart:async'; void main() async { var c = StreamController<int>(); c.add(1); await for (var n in c.stream) { print(n); } print('End'); } -
What happens when two listeners call
listenon the same plain controller's stream?
Challenge
- Write a
Thermostatclass whoseset(double c)addscif it is between10and30, and otherwise adds the error'$c is out of range'. Set21.0,35.0,18.5. - Write a
ShoppingListclass withadd(String item)anddone(). Inmain, collect every item withawait forinto a list and printItems: [Bread, Milk, Eggs].
Practice Answers
Question 1 — Solution
import 'dart:async';
void main() {
var controller = StreamController<String>();
controller.stream.listen(print);
controller.add('Karachi');
controller.add('Lahore');
}Karachi
LahoreQuestion 2 — Solution
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);
}
}1
2
3Question 3 — Solution
import 'dart:async';
void main() {
var controller = StreamController<String>();
controller.stream.listen(print, onDone: () => print('Finished'));
controller.add('A');
controller.add('B');
controller.close();
}A
B
FinishedQuestion 4 — Solution
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);
}Mark: 90
Error: Missing mark
Mark: 70Question 5 — Solution
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();
}Count: 1
Count: 2
Count: 3Question 6 — Solution
import 'dart:async';
void main() {
var controller = StreamController<int>(
onListen: () => print('Listening'),
onCancel: () => print('Cancelled'),
);
controller.stream.listen(print);
controller.add(5);
controller.close();
}Listening
5
CancelledWhen the stream closes, the subscription ends, so onCancel runs too.
Question 7 — Solution
1The 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
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);
}Set to 21.0
Error: 35.0 is out of range
Set to 18.5Question 10 — Solution
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');
}Items: [Bread, Milk, Eggs]Quick Revision
- Which library do you import to use
StreamController? - What are the two ends of a controller?
- Which methods send a value, send an error, and finish the stream?
- Why keep the controller private?
- What error does a second
listengive? - When do
onListenandonCancelrun? - What happens to an
await forloop if the controller is never closed?
Key Takeaways
- A
StreamController<T>(fromdart:async) lets your code push events into a stream. addsends a value,addErroran error, andcloseends the stream.- Events arrive asynchronously; events added before a listener arrives are kept.
- Keep the controller private and expose a
Streamgetter, so only the class adds events. - A plain controller's stream allows one listener; a second throws a
Bad stateerror. onListenandonCancelrun when the listener arrives and leaves.- Forgetting
closegives no error —await forandonDonesimply never finish. - Adding after
closethrowsCannot 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.