Asynchronous Programming in Flutter (Dart) | Futures
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: theBuildContext
and theAsyncSnapshot
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 usingsnapshot.hasData
and if there was an error, it can accessed usingsnapshot.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! ❤
References
1. https://api.dart.dev/stable/3.3.3/dart-async/Future-class.html
2. https://dart.dev/codelabs/async-await
3. https://dart.dev/libraries/dart-async
4. https://www.bezkoder.com/dart-future/
5. https://www.youtube.com/watch?v=OTS-ap9_aXc&ab_channel=Flutter.
6. https://api.flutter.dev/flutter/dart-async/Future-class.html#constructors