Asynchronous Cells
Live Cells provides a number of tools for handling data that is fetched / computed asynchronously.
Futures in Cells
A cell can hold and perform computations on a Future, which
represents an asynchronously computed value in Dart, just like any
other value. Thus, to perform a computation on asynchronous data,
simply define a computed cell with an async computation function and
use await to wait for the values in the argument cells to be
computed.
final n = MutableCell(Future.value(1));
final next = ValueCell.computed(() async => await n() + 1);
nis a mutable cell holding aFuturenextis a computed cell that returns aFuture, which applies a computation on theFuturevalue held inn.
When a Future is assigned to the value of n, the value of next
is updated with the new Future held in n.
It's important to note that the values of asynchronous cells, which
remember are Futures, are updated synchronously as soon as a value
is assigned to a mutable cell. It's only the computations represented
by the Futures that are asynchronous. This is best explained by the
following example:
n.value = Future.delayed(Duration(seconds: 5), () => 2);
n.value = Future.value(3);
print(await next.value); // Prints 3
Multiple Arguments
An asynchronous cell can reference multiple argument cells, however
the argument cells should all be referenced before the first await
expression. Argument cells that are only referenced after the first
await expression will not be observed by the computed cell.
Multiple asynchronous argument cells should be referenced first and then awaited, such as in the following example:
final arg1 = MutableCell(Future.value(0));
final arg2 = MutableCell(Future.value(1));
final sum = ValueCell.computed(() async {
final a = arg1();
final b = arg2();
return (await a) + (await b);
});
or awaited at once with the following:
final arg1 = MutableCell(Future.value(0));
final arg2 = MutableCell(Future.value(1));
final sum = ValueCell.computed(() async {
final (a, b) = await (arg1(), arg2()).wait;
return a + b;
});
The following is wrong as it will result in arg2 not being observed
by sum, since it is only referenced after the first await
expression:
final arg1 = MutableCell(Future.value(0));
final arg2 = MutableCell(Future.value(1));
final sum = ValueCell.computed(() async {
final a = await arg1();
// This wont be observed by `sum`
final b = await arg2();
return a + b;
});
Wait Cells
What we've seen till this point is cells taking in a Future, from
one or more argument cells, awaiting the Future, applying a
computation on the value and producing another Future. However there
is no way for a cell to take in a Future from an argument cell,
await that Future and produce an immediate (non-future)
value. That's where wait cells come in.
A wait cell waits for a Future, held in another cell, to complete
before notifying its observers. Once the Future completes, the value
of the wait cell is updated to the completed value of the
Future. A wait cell is created from a cell holding a Future using
the
.wait
property:
final n = MutableCell(Future.value(1));
final waitN = asyncN.wait;
final next = ValueCell.computed(() => waitN() + 1);
Notice in this definition of next, the computation function is not
an async function and the value of n is not awaited. Like all
properties that return cells, wait returns a keyed cell. This means
waitN can be omitted and the example can be simplified to the
following:
final n = MutableCell(Future.value(1));
final next = ValueCell.computed(() => n.wait() + 1);
When a value is assigned to n, the value of next is not updated
immediately. Instead it is only updated when the Future held in n
completes. That's the effect of the n.wait cell.
Until the Future awaited by a .wait cell completes, accessing the
cell's value will result in a PendingAsyncValueError exception
being thrown. Once the Future completes, it will retain its value
until the next Future completes.
For example accessing the value of next above before the first value
update of n.wait, will result in a PendingAsyncValueError
exception being thrown. This can be handled with onError to give an
initial value to a wait cell:
final waitN = n.wait.onError<PendingAsyncValueError>(0.cell);
print(waitN.value); // Prints: 0
final next = ValueCell.computed(() => waitN() + 1);
The .wait cell only awaits Futures when it has at least one
observer.
Order of Updates
The value of .wait is updated whenever a Future that is assigned
to the cell completes. The value updates are delivered in the same
order as the Futures are assigned to the cell, and not the order in
which the Futures actually complete. This means that if a Future
a is assigned to a cell followed by a Future b, the value of
the .wait cell is always first set to the completed value of a
and then the completed value of b, even if b completes before
a.
For example with the following:
final n = MutableCell(Future.value(1));
ValueCell.watch(() => print('${n.wait()}'));
// This future completes after 30 seconds
n.value = Future.delayed(Duration(seconds: 30), () => 2);
// This future completes before the previous future
// that was assigned to `n`
n.value = Future.value(3);
The following will be printed to the console:
1
2
3
The second and third lines will both be printed after the second
Future that was assigned to n completes, which is after a delay of
30 seconds. If the updates were delivered in the order of completion,
3 would have been printed to the console before 2.
If a Future that never completes is assigned to a cell, the value of
the .wait cell will never be updated again. If there's a chance of
that happening, add a timeout on the Future before assigning it to
a cell or use .waitLast.
Multiple Arguments
The correct way to reference multiple wait cells in a computed cell is
using the .wait property on a record holding all the argument cells:
final arg1 = MutableCell(Future.value(1));
final arg2 = MutableCell(Future.value(2));
final sum = ValueCell.compute(() {
final (a, b) = (arg1, arg2).wait();
return a + b;
});
The function call syntax is used on .wait and not on arg1 and
arg2. Also there is no await. This is intentional.
The .wait property of the record (arg1, arg2) returns a cell that
holds a record of the completed values of the Futures held in cells
arg1 and arg2. In the example above the elements of the record are
immediately assigned to the local variables a and b.
You might be asking why not just do this:
final arg1 = MutableCell(Future.value(1));
final arg2 = MutableCell(Future.value(2));
final sum = ValueCell.compute(() => arg1.wait() + arg2.wait());
There is an issue with this approach. If the Futures held in arg1
and arg2 complete at different times (which they most certainly
will), the value of sum will be recomputed twice, once when the
first future completes, and again when the second future
completes. This is probably not what you want especially if the values
of arg1 and arg2 are set at the same time.
To demonstrate the difference, consider the following watch function:
ValueCell.watch(() {
print('${sum()}');
});
And consider the following Futures assigned to arg1 and arg2 in a batch:
MutableCell.batch(() {
arg1.value = Future.delayed(Duration(seconds: 5), () => 20);
arg2.value = Future.delayed(Duration(seconds: 10), () => 30);
});
With the first (correct) definition of sum the following will be
printed to the console, by the watch function, after the values are
assigned to arg1 and arg2:
50
This is probably what you expect.
With the second definition the following will be printed:
22
50
The first line is printed when the Future held in arg1 completes
after five seconds. The second line is printed when the Future held
in arg2 completes after ten seconds.
If this isn't an issue for your application logic then you can go ahead and use the second definition.
Avoid mixing .wait with cells holding immediate values:
final sum = ValueCell.computed(() => a.wait() + b());
The value of a.wait is only updated when the Future held in a
completes. Whilst not strictly wrong, this can lead to some surprising
behaviour if a and b both update their values at the same time.
The following is recommended instead:
final sum = ValueCell.computed(() async => await a() + b).wait;
Latest Futures Only
The
.waitLast
property is like .wait however with one important difference. If the
value of the cell is updated before the Future that was previously
held in the cell completes, the previous Future is ignored and the
value of .waitLast is not updated when it completes.
final n = MutableCell(Future.value(1));
ValueCell.watch(() {
print('${n.waitLast()}');
});
n.value = Future.delayed(Duration(seconds: 30), () => 2);
n.value = Future.value(3);
n.value = Future.delayed(Duration(seconds: 10), () => 4);
The only value printed to the console is:
4
This is because it was the last value that was assigned to n and it
was assigned before any of the preceding Futures had a chance to
complete. Even when the second Future completes after thirty
seconds, the value of n.waitLast will not be updated to 2.
This is useful in two scenarios:
- To "cancel" a
Futurethat is taking too long to complete, by assigning a newFutureto the cell. - Debouncing (we'll see how to do this in the next section).
The .awaited cell is similar to .waitLast, however it does not
retain the completed value of the previous Future. If the current
Future has completed, the value of the .awaited cell is the
completed value of the Future. If the Future has not completed, a
PendingAsyncValueError exception is thrown when accessing the value
of .awaited.
The
.initialValue(...)
method, on all cells can be used to handle UninitializedCellError
and PendingAsyncValueError, by returning the value of another cell:
final f = Future.delayed(Duration(seconds: 10), 1)
.cell
.awaited
.initialValue(0.cell);
// The value of f is `0` until its value is initialized,
// which happens when the Future completes.
print(f.value) // Prints: 0
If you only want to handle PendingAsyncValueError, use
loadingValue
instead.
Both initialValue and loadingValue return keyed cells, which means
the returned cells can be used within ValueCell.computed without
assigning them to a local variable first. However, this only works
if the initial value cell, provided to initialValue/loadingValue,
is also a keyed cell. For constant cells this is the case when the
constant value type defines == and hashCode such that different
objects representing the same value compare equal under ==. Lists
and Maps do not satisfy this requirement.
Here's an example demonstrating the difference between .waitLast and
.awaited:
final f = MutableCell(Future.value(1));
final waitLast = f.waitLast.initialValue(0.cell);
final awaited = f.awaited.initialValue(0.cell);
ValueCell.watch(() {
print('.waitLast: ${waitLast()}');
});
ValueCell.watch(() {
print('.awaited: ${awaited()}');
});
await Future.delayed(Duration(seconds: 1));
This will result in the following values being printed to the console,
which is the initial value provided with initialValue(0.cell):
.waitLast: 0
.awaited: 0
The exact order in which the lines are printed may vary, since they are printed from different watch functions.
When the initial future completes, the following is printed:
.waitLast: 1
.awaited: 1
So far the two are identical, however when a new Future is assigned
to f:
f.value = Future.value(2);
The following is printed immediately when setting f.value:
.awaited: 0
The value of .awaited is reset to the initial value, given with
initialValue(0.cell), whereas the current value of .waitLast is
retained.
When the Future completes, the following is printed:
.awaited: 2
.waitLast: 2
Checking if Complete
All cells holding a Future provide an
isCompleted
property which returns a cell that is true when the Future is
complete, and false while it is still pending.
This allows other cells to check if, and be notified when, an asynchronous operation has completed or whether its still in progress.
final complete = Future.delayed(Duration(seconds: 10))
.cell
.isCompleted
ValueCell.watch(() {
if (complete()) {
print('Complete');
}
else {
print('Loading...');
}
});
Initially "Loading..." is printed to the console. When the Future
completes, after ten seconds, "Complete" is printed.
When the cell holding the Future is updated, the value of
isCompleted is also updated to reflect the state of the new
Future.
Async State
The
.asyncState
property of cells holding Futures returns a cell that evaluates to
an
AsyncState
describing the state of the Future. This class a sealed union of the
following classes, each of which represents a different state of the
Future:
-
This represents the state of a
Futurethat is still pending. -
This represents the state of a
Futurethat has successfully completed with a value, accessible via the.valueproperty. -
This represents the state of a
Futurethat has completed with an error. The exception thrown is accessible via the.errorproperty.
This allows you to handle the different states with pattern matching.
CellWidget.builder((_) {
FutureCell<String> futureCell;
...
return switch(futureCell.asyncState()) {
AsyncStateLoading() => Text('Loading...'),
AsyncStateData(:final value) => Text(value),
AsyncStateError(:final error) => Text('Error: $error')
};
});
The AsyncState class also provides the following properties:
-
isDataDoes the state represent a
Futurewhich has completed to avalue? -
isErrorDoes the state represent a
Futurewhich completed with anerror? -
isLoadingDoes the state represent a
Futurethat is still pending? -
lastValueThe last value that was loaded successfully.
- If the state is an
AsyncStateDatathis is identical to thevalueproperty. - If the state is an
AsyncStateLoadingorAsyncStateError, this is thevalueof the lastAsyncStateDatato be held in the cell.
If a value hasn't been loaded successfully yet, the value of this property is
null.importantIf you replace a
Future,a, held in the cell with anotherFuture,b, whileais still pending,lastValuewill never be equal to the completed value ofaeven if it completes successfully andbcompletes with an error. - If the state is an
Delays and Debouncing
Live Cells provides a
delayed(...)
method on cells. This method returns a cell that holds a Future that
completes with the same value as the value of the cell, on which
delayed was called, but after a given delay.
final n = MutableCell(0);
ValueCell.watch(() {
final value = n.delayed(Duration(seconds: 3)).wait;
print('$value');
});
n.value = 1;
n.value = 2;
n.value = 3;
This will result in the following being printed to the console after a delay of three seconds:
1
2
3
The delay is from the time the value is assigned to the cell, and not
since the last time the delayed cell was updated. This means that in
the above example all three values are printed at once, since they are
assigned to the cell at approximately the same time.
delayed(...) returns a keyed cell which is why we were able to use
it directly in the watch function without assigning the returned cell
to a local variable first.
When delayed(...) is used with .waitLast, the result is
effectively a debouncing of the cell's value. Debouncing is a
technique for preventing a task, in this case updating the value of a
cell, from running too frequently. This is especially useful for
implementing a "search as you type" functionality.
We can demonstrate the effect of delayed(...) followed by waitLast
with the following widget:
CellWidget.builder((_) {
final content = MutableCell('');
final debounced = content
.delayed(Duration(seconds: 3)
.waitLast
.initialValue(''.cell);
return Column(
children: [
LiveTextField(
content: content
),
Text('You wrote:'),
Text(debounced())
]
);
});
In this example, a cell is bound to the content of a
LiveTextField. The cell is then debounced with
delayed(...).waitLast and its value is displayed in the data of a
Text widget. Whatever you write in the text field is echoed in the
Text below it but only after a three second delay after you stop
typing.
Practically, to implement a search as you type functionality, you'd
reference the debounced cell in an asynchronous computed cell which
loads the search results from a backend server:
final search = MutableCell('');
final debounced = search
.delayed(Duration(seconds: 3))
.waitLast;
final results = ValueCell.computed(() async {
// A hypothetical searchItems function which
// performs the HTTP request
return await searchItems(debounced());
});
This would be used with a UI definition similar to the following:
Column(
children: [
// A text field for the search term
LiveTextField(content: search),
// Display results
CellWidget.builder((_) {
items = results.waitLast
.initialValue(const [].cell);
ListView.builder(
itemCount: items.length()
itemBuilder: (_, index) =>
ItemWidget(items[index.cell])
);
});
]
);