Zum Hauptinhalt springen

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 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 fetch 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 @riverpod or @Riverpod(). This annotation can be placed on global functions or classes.
Through this annotation, it is possible to configure the provider.

For example, we can disable "auto-dispose" (which we will see later) by writing @Riverpod(keepAlive: true).

The Notifier

When a @riverpod annotation is placed on a class, that class is called a "Notifier".
The class must extend _$NotifierName, where NotifierName is class name.

Notifiers are responsible for exposing ways to modify the state of the provider.
Public methods on this class are accessible to consumers using ref.read(yourProvider.notifier).yourMethod().

note

Notifiers should not have public properties besides the built-in state, as the UI would have no mean to know that state has changed.

The build method

All notifiers must override the build method.
This method is equivalent to the place where you would normally put your logic in a non-notifier provider.

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.

info

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.

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'),
);
}
}
info

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);
}
pros
  • 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.
cons
  • 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;
}
pros
  • 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 regarless of the server implementation. It can be especially useful if your GET request is more complex, such as if it has filters/sorting.
cons
  • 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]);
}
info

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();
pros
  • This approach should work regarless of the server implementation.
  • Only a single network-request is needed.
cons
  • 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 snipper 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:

A button which turns red when the operation failed

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: MaterialStateProperty.all(
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(),
]
],
);
},
);
}
}