Skip to main content

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.

Example of an asynchronous cell
final n = MutableCell(Future.value(1));

final next = ValueCell.computed(() async => await n() + 1);
  • n is a mutable cell holding a Future
  • next is a computed cell that returns a Future, which applies a computation on the Future value held in n.

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 either be referenced first and then awaited, such as in the following example:

An asynchronous computed cell with multiple arguments
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 using 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;
});
danger

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:

Using .wait cells
final asyncN = MutableCell(Future.value(1));
final n = asyncN.wait;

final next = ValueCell.computed(() => n() + 1);

Notice in this definition of next, the computation function is not an async function and the value of n is not awaited. Since wait is a property it returns a keyed cell, like all properties that return cells. This means the n variable above can be omitted and the above example can be simplified to the following:

final n = MutableCell(Future.value(1));

final next = ValueCell.computed(() => n.wait() + 1);
note

asyncN has been renamed to n.

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);
important

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:

Ordering of .wait cell updates
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.

caution

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, more on this later.

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:

Multiple argument wait 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;
});
note

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 first 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.

caution

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.

Example of .waitLast

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 Future that is taking too long to complete, by assigning a new Future to 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, an PendingAsyncValueError exception is thrown when accessing the value of .awaited.

tip

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.

caution

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
note

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 of, 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:

  • AsyncStateLoading

    This represents the state of a Future that is still pending.

  • AsyncStateData

    This represents the state of a Future that has successfully completed with a value, accessible via the .value property.

  • AsyncStateError

    This represents the state of a Future that has completed with an error. The exception thrown is accessible via the .error property.

This allows you to handle the different states using a switch state and 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:

  • isData

    Does the state represent a Future which has completed to a value.

  • isError

    Does the state represent a Future which completed with an error.

  • isLoading

    Does the state represent a Future that is still pending.

  • lastValue

    The last value that was loaded successfully.

    • If the state is an AsyncStateData this is identical to the value property.
    • If the state is an AsyncStateLoading or AsyncStateError, this is the value of the last AsyncStateData to be held in the cell.

    If a value hasn't been loaded successfully yet, the value of this property is null.

    important

    If you replace a Future a held in the cell with another Future b while a is still pending, lastValue will never be equal to the completed value of a even if it completes successfully and b completes with an error.

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.

Example of .delayed(...)
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
important

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.

tip

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, we've bound a cell to the content of a LiveTextField. We've debounced the cell with delayed(...).waitLast and bound the debounced cell to 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);

Column(
children: items()
.map((e) => ItemWidget(e))
.toList()
);
});
]
);