Why use async/await instead of Future.then?
These two methods do almost the same:
They get some objects from what could be a network call, wait for the result, then get the title of each object, and handle possible errors if the awaited operation fails.
The first one is the old way, and it is called ‘The Future API’. You may still find it in old tutorials. The second one is the modern way, and it is called ‘Native Asynchrony’. It relies on the same Future API under the hood but wraps it with a cleaner syntax.
You should not use the Future API directly for the following reasons:
Exceptions are not handled before the gap
Imagine the repository implementation like this:
It has 2 parts:
_prepare()
is called synchronously beforegetObjects()
returns._parseObjects(map)
is called when the raw data arrives from the network.
What happens between them is called an ‘asynchronous gap’. Essentially it means that getObjects()
returns a future, and at some later point the callback in then
runs.
catchError
in the outer code gives a false sense of safety, but it only handles exceptions in _parseObjects()
. This is just because catchError
is a method that is attached to whatever getObjects()
returns. If it does not return normally, catchError
cannot attach to it.
Follow these links to run two snippets with exceptions before and after the gap in your browser.
So, for the two methods to be equivalent, the first one needs two identical error handlers:
Note that this problem only happens if the throwing function is synchronous. If the called function is async
, then throw
starts the gap, and exceptions from there will be caught even before any await
:
But the calling code cannot rely on the function being async
, this can change any time. So this brings uncertainty instead of seeming safety.
Harder to set breakpoints
With most IDEs, you cannot set a breakpoint inside your then
callback function unless you break its body into a separate line:
But it is hard to set breakpoints in lines like this:
.then((articles) => articles.map((article) => article.title)),
Harder to move code across the gap
If you need to move doSomething()
after the objects arrive, you only have messy options. You may expand your then
callback to multiple lines (1). Or you may wrap it into a separate then
(2). Compare this to just moving the line in native asynchrony.
Easy to lose track of the results
In the snippet above, then
on line 3 receives the result from getObjects()
, while then
on line 7 receives the result from the previous then
. Imagine you need to add yet another then
after that, and access articles
there. You will have to update every earlier callback before it to return its argument. This is redundant and easy to lose track of.
Callbacks are dirty
- A callback breaks the sequence of execution. For humans, it is harder to follow a function with nested functions.
- A callback repeats identifiers too much. You definitely want fewer articles here:
.then((articles) => articles.map((article) => article.title)),
You want this:_titles = articles.map((article) => article.title);
- A callback uses one arrow operator. If you add one to yourself, it makes two as in the example. Two arrow operators in one line are harder to follow.
- A callback uses extra
(parentheses)
and possibly{braces}
. Asynchronous code does not. - A callback body is indented. Each indentation is an obstacle when reading. Each indentation takes 2 characters of your 80 characters limit that the Dart style guide recommends.
In a few words, callbacks are boilerplate around what you really need to run. Eliminate the boilerplate.
Anonymity gives no hints
With native asynchrony, you write the result into a separate variable as it arrives, in this case:
final articles = await ...
… and its name becomes a hint. This way it is clear what the objects are. Sure, in the old way, you can hint the object type on the next line with .map((article) => ...
, but the reader still needs to grasp two lines for the picture. With native asynchrony, each line makes sense on its own.
Harder to format
The Future API gives you a chain of methods in one expression spanning multiple lines. In many cases, the Dart formatter will mess it up. On the other hand, with native asynchrony you mostly have short lines which are OK to the formatter.
Harder to switch between sync and async
Suppose getObjects()
stopped being asynchronous. Drop await
, and you’re good. But with Future API you need to dismantle the whole .then
and .catchError
thing.
Or suppose you had it the synchronous way from the beginning. And then getting the objects became asynchronous. You may just add async
and await
, and you got it. But if you are only accustomed to the old way, you would have a lot of refactoring wrapping it into then-catchError
chains.
Learn native asynchrony
So these are not equal options. Go ahead and learn async-await
here:
- https://dart.dev/guides/language/language-tour#asynchrony-support
- https://dart.dev/codelabs/async-await
Use cases for the Future API
I only remember one case where I needed to use then
:
Before saving some data, we need to validate it, and the validation is asynchronous. If valid, the data is saved. But we need to return both futures so the outer code can show different progress indicators for the two operations.
In this case, we cannot await
the first future because then we would have to return a single future from _onSavePressed
which is not what we need.
One way around this is to pass validationFuture
into _saveIfValid()
and await it there like this:
But that adds a new responsibility of awaiting to _saveIfValid
which is totally alien there. I went with a simple then
.
So go ahead and learn the old Future API as well: