Action Cells
An action cell is a cell that does not hold a value. In-fact, the cell
type of an action cell is ValueCell<void>
. The purpose of an action
cell is to notify its observers when an event, such as a button press,
has taken place.
Action cells are created using the ActionCell
constructor, and the
observers of the cell are notified with the .trigger()
method.
final action = ActionCell();
ValueCell.watch(() {
action();
print('Action triggered');
});
// Prints: Action triggered
action.trigger();
Notice that the action cell is observed, in the watch function, just
like a regular cell. The only difference is that calling the cell does
not actually return a value, since the value type of the cell is
void
.
You can use the .observe()
method instead of calling the cell
directly, e.g. action.observe()
. There is no difference between the
two but using observe
, in this case, makes the code more
self-documenting.
To allow observing an ActionCell
but disallow triggering it, using
the trigger
method, it should be cast to ValueCell<void>
:
class MyControllerClass {
// This cell can be triggered with `_myAction.trigger()`.
final _myAction = ActionCell();
// This cell can be observed but not triggered.
ValueCell<void> get myAction => _myAction.
}
Triggering Actions
Besides being used to notify of events, action cells can be used to trigger operations. An example is retrying a failed network request.
For example consider an asynchronous cell which performs a network request:
import 'package:dio/dio.dart';
final dio = Dio();
final countries = ValueCell.computed(() async {
final response =
await dio.get('https://api.sampleapis.com/countries/countries');
return response.data;
});
This example uses the dio package for HTTP requests.
As the cell is defined in the example above, there is no way to retry the network request if it fails for some reason e.g. the Internet connection is down, the server is down, the request times out. Observers of the cell will observe the error and can handle it by displaying an error notice, but there is no way to offer a retry functionality to the user.
This is where ActionCell
s come in handy. All we need to do to add
retry functionality is to define an ActionCell
, which will serve to
trigger the retrying of the request, and observe it in our cell defined
above.
final retry = ActionCell();
final countries = ValueCell.computed(() async {
// Observe the retry cell.
retry.observe();
final response =
await dio.get('https://api.sampleapis.com/countries/countries');
return response.data;
});
We've observed the retry action cell at the start of the cell computation function. To retry the network request all we need to do is trigger the retry cell with:
retry.trigger();
This will cause the compute value function of the countries
cell to
be run again, because retry
is observed by countries
.
ActionCell
s can be defined in the build method/function of a
CellWidget
just like any other cell.
Practical Example
This is very handy for implementing a reusable error handling widget that displays the result of a request when it is successful and an error notice with a button to retry the request, when it fails.
To achieve this we'll need to define a widget that takes a child
widget as a cell, and an ActionCell
for retrying the action.
class ErrorHandler extends CellWidget {
final ValueCell<Widget> child;
final ActionCell retry;
const ErrorHandler({
super.key,
required this.child,
required this.retry
});
Widget build(BuildContext context) {
try {
return child();
} catch (e) {
return Column(
children: [
const Text('Something went wrong!'),
ElevatedButton(
onPressed: retry.trigger,
child: const Text('Retry')
)
]
);
}
}
}
A couple of things to note from this definition:
- The
child
widget is provided as a cell rather than a widget to allow us to handle errors usingtry
andcatch
. - The value of
child
is referenced inside atry
block, and returned if the child widget is created successfully. - If an error occurred while computing the
child
widget, an error notice with a retry button is displayed. - The
onPressed
handler of the retry button callsretry.trigger()
which triggers the retry action.
Before we update the definition of the countries
cell let's define a
simple data model:
class Country {
/// Name of the country
final String name;
const Country({
required this.name
});
Country.fromJson(Map<String, dynamic> json) :
name = json['name'];
}
This defines a model Country
with a single field name
, and a
constructor for creating instances of the model from JSON.
Let's place the definition of the countries
cell inside a function
that creates the cell using a given retry cell:
FutureCell<List<Country>> countries(ValueCell<void> retry) =>
ValueCell.computed(() async {
// Observe the retry cell.
retry.observe();
final response =
await dio.get('https://api.sampleapis.com/countries/countries');
final results = response.data as List;
return results.map((e) => Country.fromJson(e)).toList();
});
FutureCell
is a shorthand for a ValueCell
that holds a Future
,
e.g. FutureCell<List>
is equivalent to ValueCell<Future<List>>
.
This definition of the countries cell returns a list of Country
s,
whereas the previous definition returns the raw parsed JSON response.
The ErrorHandler
widget can now be used as follows
class Countries extends CellWidget {
static final _loadingData = <Country>[];
Widget build(BuildContext context) {
final retry = ActionCell();
final results = countries(retry);
return ErrorHandler(
retry: retry,
child: ValueCell.computed(() {
final data = results.awaited
.loadingValue(_loadingData.cell);
final countries = data();
return ListView.builder(
itemCount: countries.length,
itemBuilder: (_, index) => Text(
countries[index].name,
textAlign: TextAlign.center,
)
);
})
);
}
}
Some points to note from this example:
-
The same retry action cell is passed to the countries cell and the
ErrorHandler
widget. This allows the widget to directly trigger the retry action. -
The child widget is defined in a computed cell, which awaits the
Future
held in the countries cell using.awaited
. -
.loadingValue(_loadingData)
is used so that the cell evaluates to the empty list while the response is still pending, rather than throwing aPendingAsyncValueError
.
Notice we've successfuly decoupled the data loading, presentation and error handling steps from each other and factored them out into three reusable components:
-
The countries cell, which is only concerned with performing the HTTP request that loads the data, and doesn't care how that data is presented, how errors are handled nor how the operation is retried.
-
The
child
widget, which is only concerned with presenting the data to the user when the request is successful, and not handling errors. -
The
ErrorHandler
widget, which is agnostic to how the data is loaded and presented, but is only concerned with handling errors.
And note we were able to achieve all of this reusability and separation of concerns without a mess of callbacks and controller objects.
We've glossed over styling and UI design in these examples, since
that's beyond the scope of this library, but we can make this example
more user friendly by providing a loading indication while the data is
loading, rather than showing an empty list. We can do this easily
using, the .isCompleted
property of cells holding a Future
, and
the skeletonizer package.
To do that we'll first update _loadingData
to a list of five
placeholder items. These wont be displayed but are required by
skeletonizer to display a shimmer effect:
static final _loadingData =
List.filled(5, const Country(name: 'placeholder'));
Finally the child
widget is wrapped in a Skeletonizer
widget that
draws its child elements using a Shimmer effect when .isCompleted()
is
false, that is while the data is still loading:
ValueCell.computed(() {
final data = results.awaited
.loadingValue(_loadingData.cell);
final countries = data();
return Skeletonizer(
enabled: !results.isCompleted()
child: ListView.builder(
itemCount: countries.length,
itemBuilder: (_, index) => Text(
countries[index].name,
textAlign: TextAlign.center,
)
)
);
})
A summary of the changes:
-
The child widget is wrapped in a
Skeletonizer
, from the skeletonizer package, that displays its children using a shimmer effect while it is enabled. -
The
Skeletonizer
is only enabled whenisCompleted()
is false,that is when the response is still pending. -
The loading data is now set to a list containing five dummy items. These items aren't actually displayed but are required by
Skeletonizer
to display a shimmer effect.
When using loadingValue
directly within a build
method or a
ValueCell.computed
, the value passed to loadingValue
must
implement ==
and hashCode
, such that different objects
representing the same value compare equal under ==
. If that's not
the case, then the loading value should be stored in a static
final
variable on the class defining the widget, as was done in this
example.
Chaining Actions
An action cell can be chained to another cell, in which case
triggering the cell triggers the action cell to, which it is
chained. Chained action cells are created with the .chain(...)
method which takes an action function that is called when the chained
action cell is triggered.
final action = ActionCell();
final chained = action.chain(() {
action.trigger();
});
In this example a chained
action cell is created, which is chained
to action
. When trigger()
is called on chained
, the function
provided to .chain(...)
is called. In this example the function
calls trigger()
on action
. As a result triggering chained
triggers action
.
The observers of chained
are notified whenever action
notifies its
observers, whether due to being triggered from chained
or directly.
The function provided to .chain
can do more than merely calling
.trigger()
on another action cell. It can also omit a call to
trigger if some condition is not met.
For example consider the following action cell, which asks the user
whether to proceed with the action or not, via a hypothetical
showConfirmPrompt
function:
final chained = action.chain(() async {
final confirm = await showConfirmPrompt();
if (confirm) {
action.trigger();
}
});
The original action is only triggered if showConfirmPrompt()
returns
true.
This allows you to package the "confirmation" functionality in an
extension on ActionCell
:
extension ConfirmActionExtension on ActionCell {
ActionCell confirmed() => action.chain(() async {
final confirm = await showConfirmPrompt();
if (confirm) {
action.trigger();
}
});
}
Which can then be applied on any action cell:
final submitForm = ActionCell();
final confirmed = submitForm.confirmed()
// This will show a confirmation prompt before
// triggering the submitForm action.
confirmed.trigger();
This example also demonstrates that you can provide an asynchronous
function to .chain
. However, keep in mind that if
chained.trigger()
is called inside MutableCell.batch(...)
, the
batch update will not be in effect when the original action is
triggered. This only applies if an asynchronous function is provided
to .chain
.