Exploring the Power of Completers in Dart and Flutter 🚀

Sangini Gupta
5 min readJun 16, 2024

--

Hey there, Flutter enthusiasts! 🐦

Today, let’s dive into an amazing class in Dart that will make your asynchronous programming life easier: **Completers**. We’ll explore what they are, how to use them, and how they can help you convert a stream result into an async function. Buckle up, and let’s get started! 🎉

What is a Completer? 🤔

In Dart, a Completer is an object that allows you to create a `Future` and complete it later. This is incredibly useful when you want to perform some asynchronous operations and control the completion of the `Future` yourself.

Think of a Completer as a promise that you can fulfil whenever you are ready. This gives you great flexibility in managing asynchronous code.

Why Use Completers? 🛠️

Completers are handy in scenarios where:

  • You need to wait for multiple asynchronous events to complete before proceeding.
  • You want to convert a stream or callback into a `Future`.
  • You want to manage and complete a `Future` outside of the function that creates it.

Converting a Stream to an Async Function 🌊➡️🚀

Completers are extremely helpful when we want to convert a stream’s result into an async function. Imagine you have a stream that emits events, and you want to await a specific event.

Here’s how you can achieve this:

Future<String> waitForEvent(Stream<String> eventStream, String targetEvent) async {
Completer<String> completer = Completer();

StreamSubscription<String> subscription = eventStream.listen((event) {
if (event == targetEvent) {
completer.complete(event);
}
});
// Cancel the subscription when the future completes to avoid memory leaks
completer.future.then((_) => subscription.cancel());

return completer.future;
}

Explanation 🎓

  1. Creating the Completer: Let’s create a Completer.
Completer<String> completer = Completer();

2. Listening to the Stream: We listen to the event stream. When the target event is emitted, we complete the `Future`.

StreamSubscription<String> subscription = eventStream.listen((event) {
if (event == targetEvent) {
completer.complete(event);
}
});

3. Managing the Subscription: To avoid memory leaks, we cancel the subscription once the `Future` completes.

completer.future.then((_) => subscription.cancel());

4. Returning the Future: Finally, we return the `Future` associated with the Completer.

return completer.future;

A Practical Example 📱

Let’s take a look at a practical example to see Completers in action. Suppose you have a function that connects to a Bluetooth device and returns a `Future<bool>` indicating whether the connection was successful or not based on when the stream will return the result. Here’s how you can do it with a Completer:

Future<bool> connectToDevice(
BluetoothDevice device, Duration connectionTimeout, int mtu) async {
Completer<bool> completer = Completer();
try {
await device.connect(timeout: connectionTimeout, mtu: mtu);
} catch (e) {
completer.complete(false);
}
_connectionStateSubscription = device.connectionState.listen((state) {
_connectionState = state;
if (_connectionState == BluetoothConnectionState.connected) {
completer.complete(true);
}
});
return completer.future;
}

Breaking It Down 🧩

  1. Creating a Completer: We start by creating a `Completer<bool>`. This will allow us to complete the `Future` with either `true` or `false` based on the connection result.
Completer<bool> completer = Completer();

2. Connecting to the Device: We attempt to connect to the Bluetooth device with the given timeout and MTU settings. If the connection fails (an exception is thrown), we complete the `Future` with `false`.

try {
await device.connect(timeout: connectionTimeout, mtu: mtu);
} catch (e) {
completer.complete(false);
}

3. Listening to Connection State: We subscribe to the device’s connection state stream. If the device connects successfully, we complete the `Future` with `true`.

_connectionStateSubscription = device.connectionState.listen((state) {
_connectionState = state;
if (_connectionState == BluetoothConnectionState.connected) {
completer.complete(true);
}
});

4. Returning the Future: Finally, we return the `Future` associated with the Completer. This `Future` will complete when the device connects or fails to connect.

return completer.future;

Waiting for Multiple Asynchronous Events 🚦

Suppose you need to wait for multiple asynchronous events to complete before proceeding. Completers can help you achieve this easily. Let’s say you need to fetch user data, preferences, and notifications before rendering the home screen.

Here’s how you can do it:

Future<void> fetchInitialData() async {
Completer<void> completer = Completer();
Future.wait([
fetchUserData(),
fetchUserPreferences(),
fetchUserNotifications(),
]).then((_) {
completer.complete();
}).catchError((error) {
completer.completeError(error);
});
return completer.future;
}
Future<void> fetchUserData() async {
// Simulate a network request
await Future.delayed(Duration(seconds: 2));
}
Future<void> fetchUserPreferences() async {
// Simulate another request
await Future.delayed(Duration(seconds: 1));
}
Future<void> fetchUserNotifications() async {
// Simulate yet another network request
await Future.delayed(Duration(seconds: 3));
}

Managing a Future Outside of its Function 🌟

Let’s look at a simpler example where you might need to manage and complete a `Future` outside of the function that creates it. Imagine a scenario where you need to simulate a long-running task, like a timer. You want to start the timer and complete the `Future` when the timer ends.

Here’s how you can do it:

  1. Creating the TimerManager: We create a `TimerManager` class that has a `Completer<void>`.
class TimerManager {
Completer<void> _completer = Completer();
}

2. Starting the Timer: The `startTimer` method starts a simulated timer using `Future.delayed`. When the timer ends, it completes the `Future`.

void startTimer(Duration duration) {
Future.delayed(duration, () {
if (!_completer.isCompleted) {
_completer.complete();
}
});
}

3. Using the Future: We can then access the `Future` using the `timerDone` getter and handle it as needed.

Future<void> get timerDone => _completer.future;

Full Code and Usage ⏱️:

Now we can separate the the result of the future from the place where it is invoked.

class TimerManager {
Completer<void> _completer = Completer();
Future<void> get timerDone => _completer.future;
void startTimer(Duration duration) {
// Simulate a long-running task with a delay
Future.delayed(duration, () {
if (!_completer.isCompleted) {
_completer.complete();
}
});
}
}
void main() {
TimerManager timerManager = TimerManager();
timerManager.startTimer(Duration(seconds: 5));
timerManager.timerDone.then((_) {
print("Timer completed!");
});
}

Wrapping Up 🎁

Completers are a powerful tool in Dart and Flutter for managing asynchronous code. They give you the ability to control the completion of `Futures` and can help you convert streams or callbacks into `Future`-based APIs. This can make your code more readable and easier to manage.

Happy coding, and may your futures always complete successfully! 🚀✨

Got any questions or cool examples? Share them in the comments below! 👇😊

--

--