Performing side effects
So far, we've only seen how to fetch data (aka perform a GET HTTP request).
But what about side-effects, such as a POST request?
Applications often implement a CRUD (Create, Read, Update, Delete) API.
When doing so, it is common that an update request (typically a POST) should
also update the local cache to have the UI reflect the new state.
The problem is, how do we update the state of a provider from within a consumer?
Naturally, providers do not expose a way to modify their state.
This is by design, to ensure that the state is only modified in a controlled way
and promote separation of concerns.
Instead, providers have to explicitly expose a way to modify their state.
To do that, we will use a new concept: Notifiers.
To showcase this new concept, let's use a more advanced example: A to-do list.
Defining a Notifier
Let's start with what we already know by this point: A plain simple GET request. As we saw previously in Make your first provider/network request, we could fetch a list of todos by writing:
Future<List<Todo>> todoList(TodoListRef ref) async {
// Simulate a network request. This would normally come from a real API
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}
Now that we've fetched a list of todos, let's see how we can add a new todos.
For this, we will need to modify our provider such that they expose a public API
for modifying their state. This is done by converting our provider into what
we call a "notifier".
Notifiers are the "stateful widget" of providers. They require a slight tweak to
the syntax for defining a provider.
This new syntax is as follows:
@riverpod class MyNotifier extends _$MyNotifier { @override Result build() { <your logic here> } <your methods here> }
The annotation | All providers must be annotated with For example, we can disable "auto-dispose" (which we will see later) by writing |
The Notifier | When a Notifiers are responsible for exposing ways to modify the state of the provider. note Notifiers should not have public properties besides the built-in |
The build method | All notifiers must override the This method should not be called directly. |
For reference, you might want to check Make your first provider/network request to compare this new syntax with the previously seen syntax.
A Notifier with no method outside of build
is identical to using the
previously seen syntax.
The syntax shown in Make your first provider/network request can be considered
as a shorthand for notifiers with no way to be modified from the UI.
Now that we've seen the syntax, let's see how to convert our previously defined provider to a notifier:
class TodoList extends _$TodoList {
Future<List<Todo>> build() async {
// The logic we previously had in our FutureProvider is now in the build method.
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}
}
Note that the way of reading the provider inside widgets is unchanged.
You can still use ref.watch(todoListProvider)
as with the previous syntax.
Do not put logic in the constructor of your notifier.
Notifiers should not have a constructor, as ref
and other properties aren't
yet available at that point. Instead, put your logic in the build
method.
class MyNotifier extends ... {
MyNotifier() {
// ❌ Don't do this
// This will throw an exception
state = AsyncValue.data(42);
}
Result build() {
// ✅ Do this instead
state = AsyncValue.data(42);
}
}
Exposing a method to perform a POST request
Now that we have a Notifier, we can start adding methods to enable
performing side-effects.
One such side-effect would be to have the client POST a new todo.
We could do so by adding an addTodo
method on our notifier:
class TodoList extends _$TodoList {
Future<List<Todo>> build() async => [/* ... */];
Future<void> addTodo(Todo todo) async {
await http.post(
Uri.https('your_api.com', '/todos'),
// We serialize our Todo object and POST it to the server.
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
}
}
Then, we can invoke this method in our UI using the same Consumer
/ConsumerWidget
we saw in Make your first provider/network request:
class Example extends ConsumerWidget {
const Example({super.key});
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () {
// Using "ref.read" combined with "myProvider.notifier", we can
// obtain the class instance of our notifier. This enables us
// to call the "addTodo" method.
ref
.read(todoListProvider.notifier)
.addTodo(Todo(description: 'This is a new todo'));
},
child: const Text('Add todo'),
);
}
}
Notice how we are using ref.read
instead of ref.watch
to invoke our method.
Although ref.watch
could technically work, it is recommended to use ref.read
when logic is performed in event handlers such as "onPressed".
We now have a button which makes a POST request when pressed.
However, at the moment, our UI does not update to reflect the new todo list.
We will want our local cache to match the server's state.
There are a few ways to do so with their pros and cons.
Updating our local cache to match the API response
A common backend practice is to have the POST request return the new
state of the resource.
In particular, our API would return the new list of todos after adding
a new todo. One way of doing this is by writing state = AsyncData(response)
:
Future<void> addTodo(Todo todo) async {
// The POST request will return a List<Todo> matching the new application state
final response = await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
// We decode the API response and convert it to a List<Todo>
List<Todo> newTodos = (jsonDecode(response.body) as List)
.cast<Map<String, Object?>>()
.map(Todo.fromJson)
.toList();
// We update the local cache to match the new state.
// This will notify all listeners.
state = AsyncData(newTodos);
}
- The UI will have the most up-to-date state possible. If another user added a todo, we will see it too.
- The server is the source of truth. With this approach, the client doesn't need to know where the new todo needs to be inserted in the list of todos.
- Only a single network-request is needed.
- This approach will only work if the server is implemented in a specific way. If the server does not return the new state, this approach will not work.
- May still not be doable if the associated GET request is more complex, such as if it has filters/sorting.
Using ref.invalidateSelf()
to refresh the provider.
One option is to have our provider re-execute the GET request.
This can be done by calling ref.invalidateSelf()
after the POST request:
Future<void> addTodo(Todo todo) async {
// We don't care about the API response
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
// Once the post request is done, we can mark the local cache as dirty.
// This will cause "build" on our notifier to asynchronously be called again,
// and will notify listeners when doing so.
ref.invalidateSelf();
// (Optional) We can then wait for the new state to be computed.
// This ensures "addTodo" does not complete until the new state is available.
await future;
}
- The UI will have the most up-to-date state possible. If another user added a todo, we will see it too.
- The server is the source of truth. With this approach, the client doesn't need to know where the new todo needs to be inserted in the list of todos.
- This approach should work regardless of the server implementation. It can be especially useful if your GET request is more complex, such as if it has filters/sorting.
- This approach will perform an extra GET request, which may be inefficient.
Updating the local cache manually
Another option is to update the local cache manually.
This would involve trying to mimick the backend's behavior.
For instance, we would need to know whether the backend inserts new items
at the start or at the end.
Future<void> addTodo(Todo todo) async {
// We don't care about the API response
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
// We can then manually update the local cache. For this, we'll need to
// obtain the previous state.
// Caution: The previous state may still be loading or in error state.
// A graceful way of handling this would be to read `this.future` instead
// of `this.state`, which would enable awaiting the loading state, and
// throw an error if the state is in error state.
final previousState = await future;
// We can then update the state, by creating a new state object.
// This will notify all listeners.
state = AsyncData([...previousState, todo]);
}
This example uses immutable state. This is not required, but recommended.
See Why Immutability for more details.
If you want to use mutable state instead, you can alternatively do:
final previousState = await future;
// Mutable the previous list of todos.
previousState.add(todo);
// Manually notify listeners.
ref.notifyListeners();
- This approach should work regardless of the server implementation.
- Only a single network-request is needed.
- The local cache may not match the server's state. If another user added a todo, we will not see it.
- This approach may be more complex to implement and effectively duplicate the backend's logic.
Going further: Showing a spinner & error handling
With all we've seen so far, we have a button which makes a POST request
when pressed; and when the request is done, the UI updates to reflect
changes.
But at the moment, there is no indication that the request is being
performed, nor any information if failed.
One way to do so is to store the Future returned by addTodo
in the local widget state, and then listen to that future to show
a spinner or error message.
This is one scenario where flutter_hooks
comes in handy. But of course, you can also use a StatefulWidget
instead.
The following snippet shows a progress indicator while and operation is pending. And if it failed, renders the button as red:
class Example extends ConsumerStatefulWidget {
const Example({super.key});
ConsumerState<ConsumerStatefulWidget> createState() => _ExampleState();
}
class _ExampleState extends ConsumerState<Example> {
// The pending addTodo operation. Or null if none is pending.
Future<void>? _pendingAddTodo;
Widget build(BuildContext context) {
return FutureBuilder(
// We listen to the pending operation, to update the UI accordingly.
future: _pendingAddTodo,
builder: (context, snapshot) {
// Compute whether there is an error state or not.
// The connectionState check is here to handle when the operation is retried.
final isErrored = snapshot.hasError && snapshot.connectionState != ConnectionState.waiting;
return Row(
children: [
ElevatedButton(
style: ButtonStyle(
// If there is an error, we show the button in red
backgroundColor: WidgetStatePropertyAll(
isErrored ? Colors.red : null,
),
),
onPressed: () {
// We keep the future returned by addTodo in a variable
final future = ref
.read(todoListProvider.notifier)
.addTodo(Todo(description: 'This is a new todo'));
// We store that future in the local state
setState(() {
_pendingAddTodo = future;
});
},
child: const Text('Add todo'),
),
// The operation is pending, let's show a progress indicator
if (snapshot.connectionState == ConnectionState.waiting) ...[
const SizedBox(width: 8),
const CircularProgressIndicator(),
]
],
);
},
);
}
}