Asynchronous Programming in Flutter (Dart) | Futures

Jackie Moraa
8 min readApr 17, 2024

--

Flutter applications often deal with a number of asynchronous operations. So, what is an asynchronous operation? Asynchronous operations are operations where the execution of one task is not dependent on the execution of another. They are also referred to as ‘non-blocking’ operations. That is; they don’t block the main UI thread and tasks can run simultaneously. (The converse is true for synchronous operations).

A common example of asynchronous operations is fetching data over a network or reading/writing to a file or database. Such asynchronous computations provide their result as a future or as a stream. In this article, we’ll be focusing on Futures.

Futures represent a single asynchronous computation that produces a result at some point … in the future. Therefore, Futures can be seen as the promise of a value. The Future class in Flutter plays an important role in managing these asynchronous operations. The class represents the eventual result of the operation and acts as a placeholder, indicating that a value will be available at some point in the future, not immediately.

A future has three states — uncompleted, completed with a value (if the asynchronous computation succeeded) or completed with an error (if the computation failed). Since a future can be completed in two ways; either it resolves with a value or rejects with an error, you can write callbacks for either or both cases. The class therefore provides a direct way of accessing these results when the operation is done.

Creating a Future
To create a Future in Dart, you can simply use the Future class directly as it is or make use of the async and await keywords.

Future<void> loginUser() {
// some functional code
return Future.delayed(Duration(seconds: 2), () {
print ('User logged in successfully!');
});
}

In the simple example above, loginUser() returns a future which when completed will print the statement to the console. Since it doesn't return a usable value, the function has the type Future<void>. The Future.delayed named constructor is used to create a Future that computes a result and after the given duration has passed, the future is completed with that result. Above, we’re waiting for a duration of 2 seconds. It is used a lot in creating mock network services e.g., for testing.

There are other Future constructors e.g.,

Future.value: This constructor​​ creates a future that completes with a specific value. E.g., if you have a value already cached.

Future<int> valueConstructor() {
return Future.value(42);
}

valueConstructor().then((value) {
print('Value: $value');
});

Future.wait : This constructor returns a future which completes once all the provided futures have completed. It completes either with their results, or with an error if any of the provided futures fail.

Future<void> waitConstructor() {
return Future.wait([
Future.delayed(Duration(seconds: 1), () => print('Operation 1')),
Future.delayed(Duration(seconds: 2), () => print('Operation 2')),
Future.delayed(Duration(seconds: 3), () => print('Operation 3')),
]);
}

// Usage
waitConstructor().then((_) {
print('All operations completed');
});

Future.error : This constructor creates a Future that completes with an error.

Future<void> errorConstructor() {
return Future.error(Exception('An error occurred'));
}

errorConstructor().catchError((error) {
print('Error: $error');
});

Then
In the valueConstructor example above, we have made use of a method, then. The then instance method can be used to handle the result of a Future when it completes successfully. You can use it to specify a function that will be called with the result of the Future once it is available.

valueConstructor().then((value) {
print('Value: $value');
});

Here valueConstructor() returns a Future with a specific value (that is, 42). The then method is used to specify a callback function that which will take the value produced by the Future as an argument. This callback function prints “Value: 42” when the Future completes successfully. In short, we use then() to indicate the code that runs when the future completes.

Chaining ‘thens’
You can chain multiple ‘thens’ if you need to perform a sequence of asynchronous operations where each operation depends on the result of the previous one. Chaining allows the code to be structured in a readable manner with the asynchronous operations flowing sequentially.

Future<String> fetchUser() {
return Future.delayed(const Duration(seconds: 2), () {
return 'JackieMoraa';
});
}

Future<int> processUser(String data) {
return Future.delayed(const Duration(seconds: 2), () {
return data.length;
});
}

void main() {
fetchUser().then((userName) {
print('Logged in user is: $userName');
return processUser(userName);
}).then((length) {
print('Length of user name: $length');
});
}

In this example, we have the fetchUser function which simulates getting the logged in user asynchronously after a delay of 2 seconds. The processUser function then processes the length of the user name fetched after another 2 seconds delay.

In the main function, we first call fetchUser(), which returns a Future representing the user name retrieval operation. We add the first then function onto the returned Future and inside this then function, we process the user name by printing it and then returning the result by calling processUser(userName) function. We now chain another then function onto the Future returned by processUser. Inside this then function, we handle the processed username by printing its length.

Error handling
It is important to handle any errors that occur during the asynchronous operations. Then expects a value of the specific future return type, so we need a way to register a callback in case of an error. We can use the catchError function for this. catchError works just like then but instead of a value it takes in an error and it executes if the future completes with an error. So, in our example, the full implementation can look like this:

Future<String> fetchUser() {
return Future.delayed(const Duration(seconds: 2), () {
return 'JackieMoraa';
});
}

Future<int> processUser(String data) {
return Future.delayed(const Duration(seconds: 2), () {
return data.length;
});
}

void main() {
fetchUser().then((userName) {
print('Logged in user is: $userName');
return processUser(userName);
}).then((length) {
print('Length of user name: $length');
}).catchError((error) {
print('Error: $error');
});
}

Async/Await

The async and await keywords are used to make asynchronous code easier to read and understand as they help to avoid nested callbacks. So instead of directly using the Future API, consider using async/await. An async function will use the await keyword to pause execution until the Future completes, then it resume with the resolved result. Here is a simple example:

Future<void> printFiveNumbers() async {
final numbers = [1, 2, 3, 4, 5];
for (final number in numbers) {
await Future.delayed(const Duration(seconds: 1));
print(number);
}
print('Done printing!');
}

void main() {
printFiveNumbers();
}

The printFiveNumbers function is marked as async to enable the use of await. Inside the for loop, we are using await Future.delayed(Duration(seconds: 1)) to simulate an asynchronous operation which causes each number to be printed with a delay of 1 second between them. And after successfully printing all numbers, it prints ‘Done printing!’ to the console which signifies a completion of the operation.

You can also await multiple functions as shown below:

  for (final number in numbers) {
await Future.delayed(const Duration(seconds: 1));
print(number);
await Future.delayed(const Duration(seconds: 1));
print('next');
}

Error handling
Just as stated while discussing then above, it is important to catch any errors as a result of an asynchronous operation. Here, we can use the try/catch blocks. Our resulting code will look like this:

Future<void> printFiveNumbers() async {
try {
final numbers = [1, 2, 3, 4, 5];
for (final number in numbers) {
await Future.delayed(const Duration(seconds: 1));
print(number);
await Future.delayed(const Duration(seconds: 1));
print('next');
}
print('Done printing!');
} catch (error) {
print('Error: $error');
}
}

FutureBuilder

FutureBuilder is a Flutter SDK widget that simplifies working with asynchronous operations and then it updates the UI based on the results of these operations. E.g., fetching data from a remote database and updating the UI based on the different states: still loading, completed with data successfully or completed with an error. So, just as the name suggests, the FutureBuilder widget builds the UI…in the future.

The FutureBuilder constructor takes in two required parameters:

  • future: The Future object representing the asynchronous operation whose result you want to build your UI based on.
  • builder: This is a callback function that takes two arguments: the BuildContext and the AsyncSnapshot of the data returned by the Future. This method is called anytime the state of the Future object changes, and it is therefore responsible for building the UI based on that state.

As mentioned, the UI is built based on the state of the AsyncSnapshot. So, what are these states?

  • ConnectionState.none: In this state, the Future hasn't started yet.
  • ConnectionState.waiting: This is the state whereby the Future is loading.
  • ConnectionState.active: This is the state whereby the Future is loaded but the operation is not yet done.
  • ConnectionState.done: The Future has completed. In the completed state, there are two outcomes, success or error. If the Future completed successfully, the results can be accessed using snapshot.hasData and if there was an error, it can accessed using snapshot.hasError.

Using if-else and/or switch statements, you can check on the state of the snapshot and make a decision on what to show on the UI.

Below is a simple UI example, showing the FutureBuilder in action with the explanations given above:

import 'package:flutter/material.dart';

Future<String> loginUser() async {
try {
await Future.delayed(const Duration(seconds: 5));
return 'User logged in successfully!';
} catch (error) {
return 'Error: $error';
}
}

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: Colors.blue[100],
appBar: AppBar(
title: const Text('FutureBuilder Widget'),
),
body: Center(
child: FutureBuilder(
future: loginUser(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
// TODO: Handle this case.
case ConnectionState.waiting:
return const CircularProgressIndicator();
case ConnectionState.active:
// TODO: Handle this case.
case ConnectionState.done:
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return Text('Message: ${snapshot.data}');
}
}
},
),
),
),
);
}
}

N.B., I normally don’t assign the function directly to the future object as above. Instead, I assign the function to variable first, then assign that variable to the future object in the FutureBuilder.

The resulting UI:

That’s it for this article. There’s quite a lot of info on Futures, so take time to read through the docs. I have linked some useful references below. In the next article, we’ll be looking at streams. So, be on the lookout for that!

A programmer had a problem. He thought to himself, “I know, I’ll solve it with async!” has Now problems. two he
— Anonymous

Thank you for reading! ❤

--

--