In Dart, streams are a way to handle many pieces of data that come over time. Instead of getting all the data at once, streams let you work with data as it arrives, one piece at a time.
Think of streams like a river. Just as water flows continuously in a river, data flows in a stream. Each piece of data is like a drop of water, arriving one after another. This helps when working with things like user input, files, or data from the internet, where information comes bit by bit, not all at once.
What is a Stream?
A stream is a sequence of events or data values that come one after another over time. You can think of it like a TV show, where each episode arrives in order.
Just as you watch one episode after another, a stream delivers data pieces one by one. This lets your program handle each piece as it comes, without waiting for everything to be ready at once. Streams help manage continuous or long-running data smoothly and efficiently.
Types of Streams
There are two main types of streams in Dart:
- Single-subscription streams: These streams allow only one listener. Once you start listening, no one else can join. It’s like a private radio show for one person.
- Broadcast streams: These streams can have many listeners at the same time. It’s like a public TV channel where many people watch the same show together.
Choosing the right type depends on how you want your data to be shared in your app.
Creating a Stream
Streams represent a flow of data over time. In Dart, there are several ways to create streams depending on your needs. The most common ways are:
Using a StreamController
A StreamController
gives you full control over a stream. You can add data events whenever you want, send errors if something goes wrong, and close the stream when it’s done. This approach is especially useful when your data arrives irregularly or comes from multiple sources that don’t produce events all at once.
import 'dart:async';
import 'dart:math';
void main() {
final controller = StreamController<String>();
final breeds = ['Beagle', 'Bulldog', 'Dachshund', 'Labrador', 'Poodle'];
void sendRandomBreed() {
final random = Random();
final breed = breeds[random.nextInt(breeds.length)];
controller.sink.add(breed);
}
controller.stream.listen((breed) {
print('Dog breed: $breed');
});
for (int i = 0; i < 5; i++) {
Future.delayed(Duration(seconds: i), sendRandomBreed);
}
Future.delayed(Duration(seconds: 6), () {
controller.close();
});
}
Using a StreamController
gives you complete control over the data flow. You decide exactly when to add data, send errors, or close the stream. This flexibility is great for handling complex or dynamic data sources where events don’t happen in a fixed sequence.
However, setting up a StreamController
is a bit more complex than other stream creation methods. You also need to remember to properly close the stream to avoid resource leaks, which means managing the lifecycle carefully.
Creating a Stream from an Iterable
You can create a stream from a fixed list or any iterable using Stream.fromIterable()
. This method sends each item one by one automatically, making it a simple way to turn existing collections into streams.
import 'dart:async';
void main() {
final breeds = Stream.fromIterable(['Beagle', 'Bulldog', 'Dachshund']);
breeds.listen((breed) {
print('Breed: $breed');
});
}
Creating a stream from an iterable is simple and quick, especially when you already have static data. You don’t need to manage adding items or closing the stream manually — the stream automatically emits all the items and then closes.
The data is fixed and sent immediately or as fast as the listener can process it. This approach is not suitable for dynamic or delayed data because the entire list is sent in one go without pauses or timing control.
Creating a Stream from a Future
Sometimes, you need a stream that sends just one event, such as the result of a single asynchronous operation. You can use Stream.fromFuture()
to create a stream from a Future
. This stream will emit the future’s result when it completes and then close automatically.
import 'dart:async';
void main() {
final futureStream = Stream.fromFuture(
Future.delayed(Duration(seconds: 2),
() => 'Single dog breed: Beagle'
));
futureStream.listen((breed) {
print(breed);
});
}
Using Stream.fromFuture()
is an easy way to convert a single future result into a stream. It’s useful when you want to work with the stream API but only expect one event.
This stream sends only one event and then closes. It is not suitable for multiple or continuous data flows. If you need ongoing events, other stream creation methods are better choices.
Creating an Empty Stream
Sometimes you need a stream that never emits any data or closes right away. You can create such a stream using Stream.empty()
. This is helpful when you want a placeholder stream or for testing purposes.
import 'dart:async';
void main() {
final emptyStream = Stream.empty();
emptyStream.listen((event) {
print('This will never print');
}, onDone: () {
print('Stream closed immediately');
});
}
An empty stream is useful as a placeholder or for testing because it completes immediately without sending any data events. This lets you simulate a stream without actual content.
Since it never sends any data, it does nothing useful beyond signaling that the stream is closed. It’s not suitable when you expect to receive events.
Summary: Which One to Use?
Method | Use When | Pros | Cons |
---|---|---|---|
StreamController | You want to add events dynamically | Full control, flexible | More setup needed |
Stream.fromIterable | Data is static and known | Easy, quick | Fixed data, no delay |
Stream.fromFuture | Single async event | Simple for single event | Only one event |
Stream.empty | Need an empty or placeholder stream | Immediate close | No data |
This table helps you choose the right way to create streams in Dart based on your needs. If you want full control and dynamic events, use StreamController
. For simple, fixed data, Stream.fromIterable
works well. When you have a single async result, use Stream.fromFuture
. And for testing or placeholders, Stream.empty
is handy.
Why Create Streams Like This?
Creating streams lets you handle data over time instead of all at once. This is very useful for things like user input, API calls, or real-time updates, where data comes in pieces, not all together.
Each way to create streams fits different needs. Some methods are simple and quick, like turning a list into a stream. Others, like using StreamController
, give you full control when your data comes from many sources or at irregular times.
By practicing these methods, you will build better Dart apps that manage data smoothly, keep your code clean, and respond well to events as they happen. Streams make your app more powerful and easier to maintain.
Listening to a Stream
To receive data from a stream, you use the listen()
method. This tells Dart to watch the stream and run your code every time new data arrives.
For example, if you have a stream of dog breeds, you can print each breed as it comes in like this:
import 'dart:async';
void main() {
final breeds = Stream.fromIterable(['Beagle', 'Bulldog', 'Dachshund']);
breeds.listen((breed) {
print('Breed: $breed');
});
}
Here, the listen()
method waits for each dog breed from the stream and prints it right away. This way, you handle data step by step as it flows in.
Using await for
to Read Stream
You can also read data from a stream using await for
inside an async
function. This lets you handle each piece of data as it arrives, without needing to set up a separate listener.
Here’s how it looks:
import 'dart:async';
Future<void> printBreeds(Stream<String> stream) async {
await for (var breed in stream) {
print('Dog breed: $breed');
}
}
Future<void> main() async {
final breeds = Stream.fromIterable(['Beagle', 'Bulldog', 'Dachshund', 'Labrador', 'Poodle']);
await printBreeds(breeds);
}
In this example, the await for
loop waits for each dog breed from the stream and prints it one by one. This way feels like reading the stream like a simple list, but the data comes over time.
Stream Events: Data, Error, and Done
A stream sends three kinds of events:
- Data: The normal pieces of information you want, like dog breeds.
- Error: A problem happened while sending data.
- Done: The stream finished sending all data.
When you listen to a stream, you can handle each event separately.
Here’s a simple example that shows how to handle data, errors, and when the stream is done:
import 'dart:async';
void main() {
final controller = StreamController<String>();
controller.stream.listen(
(data) {
print('Data: $data');
},
onError: (error) {
print('Error: $error');
},
onDone: () {
print('Stream is done');
},
);
controller.sink.add('Beagle');
controller.sink.add('Bulldog');
controller.sink.addError('Oops! Something went wrong.');
controller.sink.add('Labrador');
controller.close();
}
In this example, the listen
method handles the data events using the first function you provide. Each time new data arrives, this function runs and processes the data—in this case, printing the dog breed.
If something goes wrong while sending data, the error is sent to the onError
handler. This lets you catch problems and respond to them, such as logging the error or showing a message.
Finally, when the stream has finished sending all data and is closed, the onDone
callback runs. This is where you can clean up or perform any final actions once the stream’s work is complete.
Pausing and Resuming Streams
Sometimes, you may want to pause a stream to stop receiving data temporarily and then resume it later. This is useful when you need to take a break, process data slowly, or manage resources better.
You can pause a stream by calling pause()
on the subscription returned by listen()
. To continue receiving data, call resume()
.
Here’s a simple example using a music playlist stream. The stream sends song names one by one. We pause and resume the stream to simulate pausing and playing music:
import 'dart:async';
void main() {
final songs = Stream.fromIterable(['Song A', 'Song B', 'Song C', 'Song D']);
final subscription = songs.listen((song) {
print('Playing: $song');
});
// Pause after 1 second
Future.delayed(Duration(seconds: 1), () {
print('Music paused');
subscription.pause();
});
// Resume after 3 seconds
Future.delayed(Duration(seconds: 3), () {
print('Music resumed');
subscription.resume();
});
}
In this example, the stream starts playing songs. After one second, it pauses the stream, stopping new songs from playing. Then after three seconds, it resumes playing the remaining songs. This shows how you can control the flow of data by pausing and resuming streams.
Stream Transformation
Streams let you change or filter data as it flows by using methods like map
and where
. These help you create a new stream with transformed or filtered values without changing the original stream.
map
changes each item.where
keeps only the items that match a condition.
Here’s an example that filters a stream of dog breeds to only include those starting with the letter “B”:
import 'dart:async';
void main() {
final breeds = Stream.fromIterable(['Beagle', 'Bulldog', 'Dachshund', 'Labrador', 'Poodle']);
final bBreeds = breeds.where((breed) => breed.startsWith('B'));
bBreeds.listen((breed) {
print('Dog breed starting with B: $breed');
});
}
In this example, the where
method creates a new stream with only dog breeds that start with “B”. When you listen to bBreeds
, you get just those filtered results. This makes working with streams more powerful and flexible.
Broadcast Streams
A broadcast stream lets many listeners receive the same events at the same time. Unlike single-subscription streams, which only allow one listener, broadcast streams are useful when you want to share data with multiple parts of your app.
For example, imagine a stream that sends live sports scores. Many users might want to listen to these updates at once — a broadcast stream makes this easy.
Here’s a simple example with multiple listeners reacting to the same stream of dog breeds:
import 'dart:async';
void main() {
final controller = StreamController<String>.broadcast();
controller.stream.listen((breed) {
print('Listener 1: $breed');
});
controller.stream.listen((breed) {
print('Listener 2: $breed');
});
controller.sink.add('Beagle');
controller.sink.add('Bulldog');
controller.sink.add('Labrador');
controller.close();
}
In this example, both listeners get each dog breed as it arrives. Broadcast streams let you easily share one stream of data with many listeners, making your app more flexible.
Stream Subscriptions
When you listen to a stream, you get a subscription. This subscription represents your connection to the stream — it controls how you receive events.
You can cancel a subscription when you no longer want to listen. This stops the stream from sending you more data and frees resources.
Managing subscriptions well is important to avoid leaks or unwanted data.
Here’s how to create, use, and cancel a subscription:
import 'dart:async';
void main() {
final stream = Stream.periodic(Duration(seconds: 1), (count) => 'Tick $count').take(5);
final subscription = stream.listen((event) {
print(event);
});
// Cancel the subscription after 3 seconds
Future.delayed(Duration(seconds: 3), () {
print('Cancelling subscription');
subscription.cancel();
});
}
In this example, the stream sends a “Tick” every second. The subscription listens and prints each tick. After 3 seconds, the subscription is cancelled, stopping any further events.
Proper subscription management helps keep your app clean and efficient.
Fun Example: Chat Room Messages
Let’s imagine a simple chat room where messages come from different users. We’ll use streams to simulate messages arriving over time, and show how to listen, filter, and respond.
Here’s a fun example:
import 'dart:async';
class ChatMessage {
final String user;
final String message;
ChatMessage(this.user, this.message);
}
void main() {
final controller = StreamController<ChatMessage>.broadcast();
// Listen to all messages
controller.stream.listen((msg) {
print('${msg.user} says: ${msg.message}');
});
// Listen only to messages from user "Amber"
controller.stream.where((msg) => msg.user == 'Amber').listen((msg) {
print('Filtered (Amber): ${msg.message}');
});
// Simulate messages arriving
controller.sink.add(ChatMessage('Edward', 'Hello everyone!'));
controller.sink.add(ChatMessage('Amber', 'Hi Edward!'));
controller.sink.add(ChatMessage('Stephen', 'Good morning!'));
controller.sink.add(ChatMessage('Amber', 'Anyone up for chess?'));
controller.close();
}
In this example, messages from different users arrive as events in a stream. Each message contains who sent it and the content, allowing the stream to carry meaningful chat data.
One listener receives every message and prints it out, so you can see the full conversation as it happens. This is like watching the whole chat room in real time.
At the same time, another listener filters the stream to show only messages from Amber. This lets you focus on one user’s messages without missing anything important.
This example shows how streams help manage live chat data smoothly. They make apps responsive by handling many events over time, while giving you control to listen to all or just some parts of the conversation.
Conclusion
Streams are a powerful way to handle many events or pieces of data that come over time. They let your program listen and react to data as it flows, making it easy to work with tasks like user input, network calls, or live updates.
To really understand streams, it helps to practice with fun examples—like dog breeds, music playlists, or chat messages. Playing with these will make you comfortable using streams in your own projects and writing clean, efficient code that handles data smoothly over time.