Dart: Stream Controllers

A StreamController in Dart lets you create and control a stream by hand. You decide what data goes into the stream, when it goes in, and when it ends.

This is useful when you don’t have data ready all at once. Instead, you want to send data step by step — like game updates, user actions, or timed messages.

In short, a StreamController gives you full control of your stream: you’re the boss of the stream data.

Creating a Basic Stream Controller

To start using a stream controller, you create one with StreamController<Type>(). You can then add data to it using .add(). Anything you add will be sent to listeners.

In this example, we send simple greeting messages:

import 'dart:async';

void main() {

  var controller = StreamController<String>();

  controller.stream.listen((msg) => print('Received: $msg'));

  controller.add('Hello');
  controller.add('Hi');
  controller.add('Hey');

  controller.close();

}

This code prints each message as it’s added. Once you’re done adding, call .close() to finish the stream.

Adding Events Manually

With a StreamController, you can manually send:

  • Regular data using .add()
  • Errors using .addError()
  • A close signal using .close()

Here’s a fun example of a joke teller that ends with an error:

import 'dart:async';

void main() {

  var controller = StreamController<String>();

  controller.stream.listen(
    (joke) => print('Joke: $joke'),
    onError: (e) => print('Oops: $e'),
    onDone: () => print('No more jokes!'),
  );

  controller.add('Why did the chicken cross the road?');
  controller.add('To get to the other side!');
  controller.addError('That joke was too old');
  controller.close();

}

This shows how you can fully control what happens in your stream — good messages, errors, and when it ends.

Using sink to Add Data

A StreamController includes a .sink property that provides a view limited to the StreamSink interface. This means through .sink, you can only add data, add errors, or close the stream—but you cannot listen or manage the stream itself. Using .sink offers a cleaner and safer way to send events, keeping the rest of the controller’s features hidden.

This separation helps you write clearer code and better control what parts of your program can add data to the stream. It is especially useful when passing the controller around, so other parts only get access to .sink and can’t accidentally listen or close the stream early.

In short, .sink acts as a simple, one-way channel for sending events into the stream, making your code tidier and more secure.

Here’s an example that sends fruit names using the sink:

import 'dart:async';

void main() {

  var controller = StreamController<String>();

  controller.stream.listen((fruit) => print('Fruit: $fruit'));

  controller.sink.add('Apple');
  controller.sink.add('Banana');
  controller.sink.add('Cherry');

  controller.sink.close();

}

Using .sink provides a simpler, safer way to add data and close the stream. It hides the full controller features, making your code cleaner and easier to manage—especially when sharing the controller across different parts of your app.

Broadcast Controllers

Sometimes, you want more than one listener to receive events from the same stream. For this, you create a broadcast StreamController using .broadcast().

A broadcast controller lets multiple listeners subscribe and get the same events.

Here’s a fun example where two players listen to game events:

import 'dart:async';

void main() {

  var controller = StreamController<String>.broadcast();

  controller.stream.listen((event) => print('Player 1: $event'));
  controller.stream.listen((event) => print('Player 2: $event'));

  controller.add('Game Started');
  controller.add('Level Up!');

  controller.close();

}

Both players receive every event because the controller is broadcast. This is perfect for things like game updates or chat messages where many parts of your app need the same data.

Controlling Streams with Timer

You can use a StreamController to send data on a timer, adding events at set intervals.

Here’s a countdown example where the controller sends numbers every second until it reaches zero:

import 'dart:async';

void main() {

  var controller = StreamController<int>();
  int count = 3;

  controller.stream.listen(
    (num) => print('Countdown: $num'),
    onDone: () => print('Go!'),
  );

  Timer.periodic(Duration(seconds: 1), (timer) {

    if (count == 0) {

      timer.cancel();
      controller.close();

    } else {
      controller.add(count--);
    }

  });

}

This shows how a stream controller can work with timers to create timed events or animations in your app.

Passing Controller Between Functions

You can share a StreamController across different parts of your code by passing it between functions. One function can add events, while another listens to them.

Here’s an example where one function sends game messages, and the main function listens:

import 'dart:async';

void startGame(StreamSink<String> sink) {

  sink.add('Start!');
  sink.add('Get ready!');

  // Yo, no further streams will be added.
  sink.close();

}

void main() {

  var controller = StreamController<String>();

  controller.stream.listen((msg) => print('Game: $msg'));

  // Pass only the sink to startGame, so it can add events safely
  startGame(controller.sink);

  // Close the controller after sending events
  controller.close();

}

By passing the .sink instead of the full controller, you limit what startGame can do—it can only add data or close the sink but cannot listen or close the controller itself. This helps keep your code safer and easier to manage.

Using Generic Types with Controllers

StreamController works with any data type, including custom classes. This lets you stream rich data, not just simple strings or numbers.

Here’s an example streaming custom Animal objects:

import 'dart:async';

class Animal {

  final String name;
  Animal(this.name);

}

void main() {

  var controller = StreamController<Animal>();

  controller.stream.listen((animal) => print('Animal: ${animal.name}'));

  controller.add(Animal('Tiger'));
  controller.add(Animal('Panda'));

  controller.close();

}

This way, you can build streams that carry detailed information, keeping your data organized and meaningful.

StreamController in Widgets (Bonus)

In Flutter, StreamController is very useful for updating UI in real-time.

You can connect the controller’s stream to a widget like StreamBuilder to rebuild the UI when new data arrives.

Here’s a simple example line showing how to use it:

StreamBuilder<String>(

  stream: controller.stream,

  builder: (context, snapshot) {
    return Text(snapshot.data ?? 'Waiting...');
  },

);

This lets your app react smoothly to data changes, making your UI lively and interactive.

Conclusion

A StreamController lets you create your own streams by adding, listening to, and closing events whenever you want. It works with many data types—strings, numbers, custom objects—and fits well with timers or multiple listeners.

The simple rule: whenever you want full control over a stream’s data flow, use a StreamController. It gives you flexibility and power to manage streams exactly how you need.