Make your first provider/network request
Network requests are the core of any application. But there are a lot of things to consider when making a network request:
- The UI should render a loading state while the request is being made
- Errors should be gracefully handled
- The request should be cached if possible
In this section, we will see how Riverpod can help us deal with all of this naturally.
Setting up ProviderScope
Before we start making network requests, make sure that ProviderScope
is added at the
root of the application.
void main() {
runApp(
// To install Riverpod, we need to add this widget above everything else.
// This should not be inside "MyApp" but as direct parameter to "runApp".
ProviderScope(
child: MyApp(),
),
);
}
Doing so will enable Riverpod for the entire application.
For complete installation steps (such as installing riverpod_lint and running the code-generator), check out Getting started.
Performing your network request in a "provider"
Performing a network request is usually what we call "business logic".
In Riverpod, business logic is placed inside "providers".
A provider is a super-powered function.
They behave like normal functions, with the added benefits of:
- being cached
- offering default error/loading handling
- being listenable
- automatically re-executing when some data changes
This make providers a perfect fit for GET network requests (as for POST/etc requests, see Performing side effects).
As an example, let's make a simple application which suggests a random activity to do when bored.
To do so, we will use the Bored API. In particular,
we will perform a GET request on the /api/activity
endpoint. This returns a JSON object,
which we will parse into a Dart class instance.
The next step would then be to display this activity in the UI. We would also make sure
to render a loading state while the request is being made, and to gracefully handle errors.
Sounds great? Let's do it!
Defining the model
Before we start, we need to define the model of the data we will receive from the API. This model will also need a way to parse the JSON object into a Dart class instance.
Generally, it is recommended to use a code-generator such as Freezed or json_serializable to handle JSON decoding. But of course, it's also possible to do it by hand.
Anyway, here's our model:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'activity.freezed.dart';
part 'activity.g.dart';
/// The response of the `GET /api/activity` endpoint.
///
/// It is defined using `freezed` and `json_serializable`.
class Activity with _$Activity {
factory Activity({
required String key,
required String activity,
required String type,
required int participants,
required double price,
}) = _Activity;
/// Convert a JSON object into an [Activity] instance.
/// This enables type-safe reading of the API response.
factory Activity.fromJson(Map<String, dynamic> json) =>
_$ActivityFromJson(json);
}
Creating the provider
Now that we have our model, we can start querying the API.
To do so, we will need to create our first provider.
The syntax for defining a provider is as followed:
@riverpod Result myFunction(MyFunctionRef ref) { <your logic here> }
The annotation | All providers must be annotated with For example, we can disable "auto-dispose" (which we will see later) by writing |
The annotated function | The name of the annotated function determines how the provider
will be interacted with. Annotated functions must specify a "ref" as first parameter. This function will be called when the provider is first read. |
Ref | An object used to interact with other providers. |
In our case, we want to GET an activity from the API.
Since a GET is an asynchronous operation, that means we will want
to create a Future<Activity>
.
Using the syntax defined previously, we can therefore define our provider as followed:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'activity.dart';
// Necessary for code-generation to work
part 'provider.g.dart';
/// This will create a provider named `activityProvider`
/// which will cache the result of this function.
Future<Activity> activity(ActivityRef ref) async {
// Using package:http, we fetch a random activity from the Bored API.
final response = await http.get(Uri.https('boredapi.com', '/api/activity'));
// Using dart:convert, we then decode the JSON payload into a Map data structure.
final json = jsonDecode(response.body) as Map<String, dynamic>;
// Finally, we convert the Map into an Activity instance.
return Activity.fromJson(json);
}
In this snippet, we've defined a provider named activityProvider
which
our UI will be able to use to obtain a random activity. It is worth nothing
that:
- The network request will not be executed until the UI reads the provider at least once.
- Subsequent reads will not re-execute the network request, but instead return the previously fetched activity.
- If the UI stops using this provider, the cache will be destroyed. Then, if the UI ever uses the provider again, that a new network request will be made.
- We did not catch errors. This is voluntary, as providers
natively handle errors.
If the network request or if the JSON parsing throws, the error will be caught by Riverpod. Then, the UI will automatically have the necessary information to render an error page.
Providers are "lazy". Defining a provider will not execute the network request. Instead, the network request will be executed when the provider is first read.
Rendering the network request's response in the UI
Now that we have defined a provider, we can start using it inside our UI to display the activity.
To interact with a provider, we need an object called "ref". You may have seen
it previously in the provider definition, as providers naturally have access
to a "ref" object.
But in our case, we aren't in a provider, but a widget. So how do we get a "ref"?
The solution is to use a custom widget called Consumer
. A Consumer
is a widget
similar to Builder
, but with the added benefit of offering us a "ref". This enables our UI to read providers.
The following example showcases how to use a Consumer
:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'activity.dart';
import 'provider.dart';
/// The homepage of our application
class Home extends StatelessWidget {
const Home({super.key});
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
// Read the activityProvider. This will start the network request
// if it wasn't already started.
// By using ref.watch, this widget will rebuild whenever the
// the activityProvider updates. This can happen when:
// - The response goes from "loading" to "data/error"
// - The request was refreshed
// - The result was modified locally (such as when performing side-effects)
// ...
final AsyncValue<Activity> activity = ref.watch(activityProvider);
return Center(
/// Since network-requests are asynchronous and can fail, we need to
/// handle both error and loading states. We can use pattern matching for this.
/// We could alternatively use `if (activity.isLoading) { ... } else if (...)`
child: switch (activity) {
AsyncData(:final value) => Text('Activity: ${value.activity}'),
AsyncError() => const Text('Oops, something unexpected happened'),
_ => const CircularProgressIndicator(),
},
);
},
);
}
}
In that snippet, we've used a Consumer
to read our activityProvider
and display the activity.
We also gracefully handled the loading/error states.
Notice how the UI was able to handle loading/error states without having to do anything special
in the provider.
At the same time, if the widget were to rebuild, the network request would correctly
not be re-executed. Other widgets could also access the same provider without
re-executing the network request.
Widgets can listen to as many providers as they want. To do so, simply add more ref.watch
calls.
Make sure to install the linter. That will enable your IDE to offer refactoring
options to automatically add a Consumer
or convert a StatelessWidget
into a ConsumerWidget
.
See Getting started for installation steps.
Going further: Removing code indentation by using ConsumerWidget
instead of Consumer
.
In the previous example, we used a Consumer
to read our provider.
Although there is nothing wrong with this approach, the added indentation
can make the code harder to read.
Riverpod offers an alternative way of achieving the same result:
Instead of writing a StatelessWidget
/StatefulWidge
return returns a Consumer
, we can
define a ConsumerWidget
/ConsumerStatefulWidget
.
ConsumerWidget
and ConsumerStatefulWidget
are effectively the fusion
of a StatelessWidget
/StatefulWidget
and a Consumer
. They behave the same
as their original couterpart, but with the added benefit of offering a "ref".
We can rewrite the previous examples to use ConsumerWidget
as followed:
/// We subclassed "ConsumerWidget" instead of "StatelessWidget".
/// This is equivalent to making a "StatelessWidget" and retuning "Consumer".
class Home extends ConsumerWidget {
const Home({super.key});
// Notice how "build" now receives an extra parameter: "ref"
Widget build(BuildContext context, WidgetRef ref) {
// We can use "ref.watch" inside our widget like we did using "Consumer"
final AsyncValue<Activity> activity = ref.watch(activityProvider);
// The rendering logic stays the same
return Center(/* ... */);
}
}
As for ConsumerStatefulWidget
, we would instead write:
// We extend ConsumerStatefulWidget.
// This is the equivalent of "Consumer" + "StatefulWidget".
class Home extends ConsumerStatefulWidget {
const Home({super.key});
ConsumerState<ConsumerStatefulWidget> createState() => _HomeState();
}
// Notice how instead of "State", we are extending "ConsumerState".
// This uses the same principle as "ConsumerWidget" vs "StatelessWidget".
class _HomeState extends ConsumerState<Home> {
void initState() {
super.initState();
// State life-cycles have access to "ref" too.
// This enables things such as adding a listener on a specific provider
// to show dialogs/snackbars.
ref.listenManual(activityProvider, (previous, next) {
// TODO show a snackbar/dialog
});
}
Widget build(BuildContext context) {
// "ref" is not passed as parameter anymore, but is instead a property of "ConsumerState".
// We can therefore keep using "ref.watch" inside "build".
final AsyncValue<Activity> activity = ref.watch(activityProvider);
return Center(/* ... */);
}
}
Flutter_hooks considerations: Combining HookWidget
and ConsumerWidget
If you have never heard about "hooks" before, feel free to skip this section.
Flutter_hooks is a package
independent from Riverpod but often used alongside it. If you are new to Riverpod,
using "hooks" is discouraged. See more in About hooks.
If you are using flutter_hooks
, you may be wondering how to combine HookWidget
and ConsumerWidget
. After all, both involve changing the extended widget class.
Riverpod offers a solution to this problem: HookConsumerWidget
and StatefulHookConsumerWidget
.
Similarly to how ConsumerWidget
and ConsumerStatefulWidget
are the fusion of Consumer
and StatelessWidget
/StatefulWidget
,
HookConsumerWidget
and StatefulHookConsumerWidget
are the fusion of Consumer
and HookWidget
/HookStatefulWidget
.
As such, they enable using both hooks and providers in the same widget.
To showcase this, we could one more time rewrite the previous example:
/// We subclassed "HookConsumerWidget".
/// This combines "StatelessWidget" + "Consumer" + "HookWidget" together.
class Home extends HookConsumerWidget {
const Home({super.key});
// Notice how "build" now receives an extra parameter: "ref"
Widget build(BuildContext context, WidgetRef ref) {
// It is possible to use hooks such as "useState" inside our widget
final counter = useState(0);
// We can also use read providers
final AsyncValue<Activity> activity = ref.watch(activityProvider);
return Center(/* ... */);
}
}