Passing arguments to your requests
In a previous article, we saw how we could define a "provider" to make
a simple GET HTTP request.
But often, HTTP requests depend on external parameters.
For example, previously we used the Bored API
to suggest a random activity to users.
But maybe users would want to filter the type of activity they want to do,
or have price requirements, etc...
These parameters are not known in advance. So we need a way to pass
these parameters from our UI to our providers.
Updating our providers to accept arguments
As a reminder, previously we defined our provider like this:
- riverpod_generator
- riverpod
// A "functional" provider
Future<Activity> activity(Ref ref) async {
// TODO: perform a network request to fetch an activity
return fetchActivity();
}
// Or alternatively, a "notifier"
class ActivityNotifier2 extends _$ActivityNotifier2 {
Future<Activity> build() async {
// TODO: perform a network request to fetch an activity
return fetchActivity();
}
}
// A "functional" provider
final activityProvider = FutureProvider.autoDispose((ref) async {
// TODO: perform a network request to fetch an activity
return fetchActivity();
});
// Or alternatively, a "notifier"
final activityProvider2 = AsyncNotifierProvider<ActivityNotifier, Activity>(
ActivityNotifier.new,
);
class ActivityNotifier extends AsyncNotifier<Activity> {
Future<Activity> build() async {
// TODO: perform a network request to fetch an activity
return fetchActivity();
}
}
When using code-generation, to pass parameters to our providers, we can add our parameters
on the annotated function itself.
For example, we could update our provider to accept a String
argument
corresponding to the type of activity desired
When not relying on code-generation, we need to tweak the syntax for defining
our providers a bit to support passing arguments. This is done by relying on
the "modifier" called "family".
In short, we need to add .family
after the type of our provider, and
an extra type parameter corresponding to the argument type.
For example, we could update our provider to accept a String
argument
corresponding to the type of activity desired:
- riverpod_generator
- riverpod
Future<Activity> activity(
Ref ref,
// We can add arguments to the provider.
// The type of the parameter can be whatever you wish.
String activityType,
) async {
// We can use the "activityType" argument to build the URL.
// This will point to "https://boredapi.com/api/activity?type=<activityType>"
final response = await http.get(
Uri(
scheme: 'https',
host: 'boredapi.com',
path: '/api/activity',
// No need to manually encode the query parameters, the "Uri" class does it for us.
queryParameters: {'type': activityType},
),
);
final json = jsonDecode(response.body) as Map<String, dynamic>;
return Activity.fromJson(json);
}
class ActivityNotifier2 extends _$ActivityNotifier2 {
/// Notifier arguments are specified on the build method.
/// There can be as many as you want, have any name, and even be optional/named.
Future<Activity> build(String activityType) async {
// Arguments are also available with "this.<argumentName>"
print(this.activityType);
// TODO: perform a network request to fetch an activity
return fetchActivity();
}
}
final activityProvider = FutureProvider.autoDispose
// We use the ".family" modifier.
// The "String" generic type corresponds to the argument type.
// Our provider now receives an extra argument on top of "ref": the activity type.
.family<Activity, String>((ref, activityType) async {
// TODO: perform a network request to fetch an activity using "activityType"
return fetchActivity(activityType);
});
// Again, for notifier we use the ".family" modifier, and specify the argument as type "String".
final activityProvider2 = AsyncNotifierProvider.autoDispose
.family<ActivityNotifier, Activity, String>(
ActivityNotifier.new,
);
// When using ".family" with notifiers, we need to change the notifier subclass:
// AsyncNotifier -> FamilyAsyncNotifier
class ActivityNotifier extends FamilyAsyncNotifier<Activity, String> {
/// Family arguments are passed to the build method and accessible with this.arg
Future<Activity> build(String activityType) async {
// Arguments are also available with "this.arg"
print(this.arg);
// TODO: perform a network request to fetch an activity
return fetchActivity(activityType);
}
}
When passing arguments to providers, it is highly encouraged to
enable "autoDispose" on the provider.
Failing to do so may result in memory leaks.
See Clearing cache and reacting to state disposal for more details.
Updating our UI to pass arguments
Previously, widgets consumed our provider like this:
AsyncValue<Activity> activity = ref.watch(activityProvider);
But now that our provider receives arguments, the syntax to consume it is slightly
different. The provider is now a function, which needs to be invoked with the parameters
requested.
We could update our UI to pass a hard-coded type of activity like this:
AsyncValue<Activity> activity = ref.watch(
// The provider is now a function expecting the activity type.
// Let's pass a constant string for now, for the sake of simplicity.
activityProvider('recreational'),
);
It is entirely possible to listen to the same provider with different arguments
simultaneously.
For example, our UI could render both "recreational" and "cooking" activities:
return Consumer(
builder: (context, ref, child) {
final recreational = ref.watch(activityProvider('recreational'));
final cooking = ref.watch(activityProvider('cooking'));
// We can then render both activities.
// Both requests will happen in parallel and correctly be cached.
return Column(
children: [
Text(recreational.value?.activity ?? ''),
Text(cooking.value?.activity ?? ''),
],
);
},
);
This is the reason why this feature is called "family": Because passing parameters to a provider effectively transforms the provider in a group of states with the same logic under the hood.
Caching considerations and parameter restrictions
When passing parameters to providers, the computation is still cached. The difference is that the computation is now cached per-argument.
This means that if two widgets consumes the same provider with the same
parameters, only a single network request will be made.
But if two widgets consumes the same provider with different parameters,
two network requests will be made.
For this to work, Riverpod relies on the ==
operator of the parameters.
As such, it is important that the parameters passed to the provider
have consistent equality.
A common mistake is to directly instantiate a new object as the parameter
of a provider, when that object does not override ==
.
For example, you may be tempted to pass a List
like so:
// We could update activityProvider to accept a list of strings instead.
// Then be tempted to create that list directly in the watch call.
ref.watch(activityProvider(['recreational', 'cooking']));
The problem with this code is that ['recreational', 'cooking'] == ['recreational', 'cooking']
is false
.
As such, Riverpod will consider that the two parameters are different,
and attempt to make a new network request.
This would result in an infinite loop of network requests, permanently
showing a progress indicator to the user.
To fix this, you could either use a const
list (const ['recreational', 'cooking']
)
or use a custom list implementation that overrides ==
.
To help spot this mistake, it is recommended to use the riverpod_lint and enable the provider_parameters lint rule. Then, the previous snippet would show a warning. See Getting started for installation steps.
Passing multiple parameters (without code-generation)
Due to the restrictions mentioned above, you may wonder how to pass multiple parameters to a provider. One solution is to use Dart 3's records:
The reason why Dart 3's records come in handy is because they
naturally override ==
and have a convenient syntax.
As an example, we could update our provider to accept both a type of activity
and a maximum price:
// We define a record representing the parameters we want to pass to the provider.
// Making a typedef is optional but can make the code more readable.
typedef ActivityParameters = ({String type, int maxPrice});
final activityProvider = FutureProvider.autoDispose
// We now use the newly defined record as the argument type.
.family<Activity, ActivityParameters>((ref, arguments) async {
final response = await http.get(
Uri(
scheme: 'https',
host: 'boredapi.com',
path: '/api/activity',
queryParameters: {
// Lastly, we can use the arguments to update our query parameters.
'type': arguments.type,
'price': arguments.maxPrice,
},
),
);
final json = jsonDecode(response.body) as Map<String, dynamic>;
return Activity.fromJson(json);
});
We can then consume this provider like so:
ref.watch(
// Using a Record, we can pass our parameters.
// It is fine to create the record directly
// in the watch call as records override ==.
activityProvider((type: 'recreational', maxPrice: 40)),
);