Skip to main content

Todos

In this example, we will build the classic TodoMVC, which has become the default way of showcasing the capabilities of a state-management library.

info

See the complete code here.

Creating the Observable state

We first start by defining the Observable state of our application. This is usually the first step in modeling your application. In this case, we have a TodoList managing a list of Todo instances.

A Todo is defined by a simple class like the following.

Todo class

import 'package:mobx/mobx.dart';

part 'todo.g.dart';

class Todo = _Todo with _$Todo;

abstract class _Todo with Store {
_Todo(this.description);

@observable
String description = '';

@observable
bool done = false;
}

You should now be familiar with the little boilerplate here.

If the boilerplate seems new, take a look at the Counter example

TodoList class

Next comes the TodoList, which manages a list of Todos. The core-state of TodoList includes:

  • a list of Todos
  • a filter that tracks the visibility-filter applied on the list of todos

This can be seen with the @observable properties.

import 'package:mobx/mobx.dart';

part 'todo_list.g.dart';

enum VisibilityFilter { all, pending, completed }

class TodoList = _TodoList with _$TodoList;

abstract class _TodoList with Store {
@observable
ObservableList<Todo> todos = ObservableList<Todo>();

@observable
VisibilityFilter filter = VisibilityFilter.all;

}

Computed Properties

To render a useful UI, we also need a bunch of derived (aka computed) properties. These properties are not inherent to the TodoList but derived from the core-state. Our UI looks like below, so you can identify the mapping of the computed properties to the various widgets.

class TodoList = _TodoList with _$TodoList;

abstract class _TodoList with Store {
// ...

@computed
ObservableList<Todo> get pendingTodos =>
ObservableList.of(todos.where((todo) => todo.done != true));

@computed
ObservableList<Todo> get completedTodos =>
ObservableList.of(todos.where((todo) => todo.done == true));

@computed
bool get hasCompletedTodos => completedTodos.isNotEmpty;

@computed
bool get hasPendingTodos => pendingTodos.isNotEmpty;

@computed
String get itemsDescription {
if (todos.isEmpty) {
return "There are no Todos here. Why don't you add one?.";
}

final suffix = pendingTodos.length == 1 ? 'todo' : 'todos';
return '${pendingTodos.length} pending $suffix, ${completedTodos.length} completed';
}

@computed
ObservableList<Todo> get visibleTodos {
switch (filter) {
case VisibilityFilter.pending:
return pendingTodos;
case VisibilityFilter.completed:
return completedTodos;
default:
return todos;
}
}

@computed
bool get canRemoveAllCompleted =>
hasCompletedTodos && filter != VisibilityFilter.pending;

@computed
bool get canMarkAllCompleted =>
hasPendingTodos && filter != VisibilityFilter.completed;

// ...

}

Actions

Actions are the semantic operations that mutate the observable state. They also create a transaction-boundary to ensure all notifications are sent out only at the end of an action. In our case, we have a few actions such as:

class TodoList = _TodoList with _$TodoList;

abstract class _TodoList with Store {
// ...

@action
void addTodo(String description) {
final todo = Todo(description);
todos.add(todo);
}

@action
void removeTodo(Todo todo) {
todos.removeWhere((x) => x == todo);
}



@action
void changeFilter(VisibilityFilter filter) => this.filter = filter;

@action
void removeCompleted() {
todos.removeWhere((todo) => todo.done);
}

@action
void markAllAsCompleted() {
for (final todo in todos) {
todo.done = true;
}
}

// ...
}

The power of co-location

Here is the complete TodoList with all the observable, computed properties and the actions.

class TodoList = _TodoList with _$TodoList;

abstract class _TodoList with Store {
@observable
ObservableList<Todo> todos = ObservableList<Todo>();

@observable
VisibilityFilter filter = VisibilityFilter.all;

@computed
ObservableList<Todo> get pendingTodos =>
ObservableList.of(todos.where((todo) => todo.done != true));

@computed
ObservableList<Todo> get completedTodos =>
ObservableList.of(todos.where((todo) => todo.done == true));

@computed
bool get hasCompletedTodos => completedTodos.isNotEmpty;

@computed
bool get hasPendingTodos => pendingTodos.isNotEmpty;

@computed
String get itemsDescription {
if (todos.isEmpty) {
return "There are no Todos here. Why don't you add one?.";
}

final suffix = pendingTodos.length == 1 ? 'todo' : 'todos';
return '${pendingTodos.length} pending $suffix, ${completedTodos.length} completed';
}

@computed
ObservableList<Todo> get visibleTodos {
switch (filter) {
case VisibilityFilter.pending:
return pendingTodos;
case VisibilityFilter.completed:
return completedTodos;
default:
return todos;
}
}

@computed
bool get canRemoveAllCompleted =>
hasCompletedTodos && filter != VisibilityFilter.pending;

@computed
bool get canMarkAllCompleted =>
hasPendingTodos && filter != VisibilityFilter.completed;

@action
void addTodo(String description) {
final todo = Todo(description);
todos.add(todo);
}

@action
void removeTodo(Todo todo) {
todos.removeWhere((x) => x == todo);
}

@action
void changeFilter(VisibilityFilter filter) => this.filter = filter;

@action
void removeCompleted() {
todos.removeWhere((todo) => todo.done);
}

@action
void markAllAsCompleted() {
for (final todo in todos) {
todo.done = true;
}
}
}

Note that all of the details of the store are together. This is an important benefit of MobX: it keeps all the state-related code together. Co-location is an important virtue and adds lot of value as you build more complex apps with sophisticated state-management.

Co-location also helps in readability of your code. By choosing Domain-specific names for your properties, actions and stores, there will be a clear mapping between the Business-domain and state code.

Connecting the UI

Now that the state has been defined, the UI becomes a natural, and visual extension of the Store. In case of MobX, we can sprinkle the Widget-tree with as many Observer widgets as needed. You can observe as little as you want or as much as needed!

MobX is smart enough to know what you are observing and automatically starts tracking it. There is no extra work needed on your part. This results in friction-free code and makes it a joy to use Observer.

At the highest level, this is what your widget tree looks like:

class TodoExample extends StatelessWidget {
@override
Widget build(BuildContext context) => Provider<TodoList>(
builder: (_) => TodoList(),
child: Scaffold(
appBar: AppBar(
title: const Text('Todos'),
),
body: Column(
children: <Widget>[
AddTodo(),
ActionBar(),
Description(),
TodoListView()
],
)));
}

We are supplying the state using a Provider<T>. This comes from the pub package and makes it easier to access the store in all of the children. Since the state is kept outside of the Widget, this is also called "Lifting the State", which makes the root Widget a StatelessWidget. Line#1 lifts the state with the Provider<TodoList>. Each child accesses the store (aka state) using the Provider.of<TodoList>() passing the BuildContext as argument.

tip

The Provider package

We strongly recommend the use of Provider<T> to supply the state of your Widget and also for the application. Lifting the state via a Provider makes it convenient and avoids using globals to track your store. It also comes with several conveniences that makes it noteworthy. This is part of the pub package and definitely worth using in your apps.

Each of the children in the root-Widget is an Observer. Let's take the simplest of all the children: Description.

Description

class Description extends StatelessWidget {
@override
Widget build(BuildContext context) {
final list = Provider.of<TodoList>(context);

return Observer(
builder: (_) => Padding(
padding: const EdgeInsets.all(8),
child: Text(
list.itemsDescription,
style: const TextStyle(fontWeight: FontWeight.bold),
)));
}
}

The builder-function passed into Observer monitors all observables referenced inside. In this case, the list.itemsDescription is referenced. The act of reading this observable is a hint to MobX to start tracking. Anytime it changes, the Observer will rebuild the widget. Notice that there is no extra work needed from your side! Using the Provider.of<TodoList>(context), we get access to the list.

TodoListView

The TodoListView is yet another Observer, in particular of the list.visibleTodos

class TodoListView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final list = Provider.of<TodoList>(context);

return Observer(
builder: (_) => Flexible(
child: ListView.builder(
itemCount: list.visibleTodos.length,
itemBuilder: (_, index) {
final todo = list.visibleTodos[index];
return Observer(
builder: (_) => CheckboxListTile(
controlAffinity: ListTileControlAffinity.leading,
value: todo.done,
onChanged: (flag) => todo.done = flag,
title: Row(
children: <Widget>[
Expanded(
child: Text(
todo.description,
overflow: TextOverflow.ellipsis,
)),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => list.removeTodo(todo),
)
],
),
));
}),
));
}
}

AddTodo

Similarly, here is the AddTodo widget that fires the list.addTodo action.

class AddTodo extends StatelessWidget {
final _textController = TextEditingController(text: '');

@override
Widget build(BuildContext context) {
final list = Provider.of<TodoList>(context);

return TextField(
autofocus: true,
decoration: const InputDecoration(
labelText: 'Add a Todo', contentPadding: EdgeInsets.all(8)),
controller: _textController,
textInputAction: TextInputAction.done,
onSubmitted: (String value) {
list.addTodo(value);
_textController.clear();
},
);
}
}

ActionBar

And finally we have the ActionBar that contains the radio-buttons to select a filter. It also has the buttons to mark all todos and remove the completed ones.

Notice that there is no business logic here. Most of the state of the UI is controlled by computed-properties. This is an important modeling tip that you can use in state-management and even unit-testing!

class ActionBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final list = Provider.of<TodoList>(context);

return Column(children: <Widget>[
Observer(
builder: (_) => Column(
children: <Widget>[
RadioListTile(
dense: true,
title: const Text('All'),
value: VisibilityFilter.all,
groupValue: list.filter,
onChanged: (filter) => list.filter = filter),
RadioListTile(
dense: true,
title: const Text('Pending'),
value: VisibilityFilter.pending,
groupValue: list.filter,
onChanged: (filter) => list.filter = filter),
RadioListTile(
dense: true,
title: const Text('Completed'),
value: VisibilityFilter.completed,
groupValue: list.filter,
onChanged: (filter) => list.filter = filter),
],
),
),
Observer(
builder: (_) => ButtonBar(
children: <Widget>[
ElevatedButton(
child: const Text('Remove Completed'),
onPressed: list.canRemoveAllCompleted
? list.removeCompleted
: null,
),
ElevatedButton(
child: const Text('Mark All Completed'),
onPressed: list.canMarkAllCompleted
? list.markAllAsCompleted
: null,
)
],
))
]);
}
}

Summary

Hope you can see the clarity of expressing the observable-state with @observable, @computed, and @action. Using the Observer widget, you get an automatically-updating Widget that renders the observable-state. To pass around the state, we rely on the Provider<T> from the pub package.

See the complete code here.

Comprehensive TodoMVC

A more comprehensive example is available in Brian Egan's Flutter Architecture Samples, that covers few other use cases like localization, persistence, unit-testing, navigation, etc. Do take a look here.