The family Modifier
This is Part 2 of Mastering Riverpod: Core Concepts. Every provider so far has been a single piece of state. But real apps need parameterized state: a provider for user 123 and one for user 456, a "todo by id" provider, a search-results provider per query. The family modifier turns a provider into a function you can call with an argument — each argument getting its own independent, cached instance.
The problem: one provider, many instances
Say you want to fetch a user by id. You can't declare a separate provider per id — there are infinitely many. You need one declaration that produces a different cached state per argument. That's exactly what family does:
// A provider PARAMETERIZED by a String id.
final userProvider = FutureProvider.family<User, String>((ref, id) async {
return ref.watch(userRepositoryProvider).fetchById(id);
});
Read it: FutureProvider.family<User, String> means "a FutureProvider of User, parameterized by a String." The create function now takes a second argument — the parameter id.
Mental model: a family is a
Mapwhere the argument is the key and the provider's state is the value.userProvider('123')anduserProvider('456')are two independent entries, each cached separately.
Using a family: pass the argument
To watch a family provider, you call it with the argument, then watch the result:
class UserScreen extends ConsumerWidget {
final String userId;
const UserScreen({super.key, required this.userId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider(userId)); // pass the arg
return userAsync.when(
data: (user) => Text(user.name),
loading: () => const CircularProgressIndicator(),
error: (e, st) => Text('Error: $e'),
);
}
}
userProvider(userId) returns a real provider (for that specific id), which you watch like any other. Different ids give different, independently-cached states — load user 123 and user 456 at the same time, no conflict:
final user1 = ref.watch(userProvider('123')); // independent
final user2 = ref.watch(userProvider('456')); // independent
This works with any provider type — Provider.family, FutureProvider.family, StreamProvider.family, and the notifier variants.
A sync example and a derived example
family isn't only for async. Any provider can be parameterized:
// Sync: a formatted label per number.
final labelProvider = Provider.family<String, int>((ref, n) => 'Item #$n');
// Derived: filter a list by a parameter.
final byCategoryProvider = Provider.family<List<Product>, String>((ref, category) {
final all = ref.watch(allProductsProvider);
return all.where((p) => p.category == category).toList();
});
// ref.watch(byCategoryProvider('books'))
The #1 gotcha: parameters need stable == / hashCode
Here's the trap that catches everyone. Because a family caches per argument (using the argument as a map key), the argument must have consistent == and hashCode. Pass something with identity-based equality — like a fresh List or a custom object without == — and the cache breaks: every call looks like a new key, so you get a fresh provider (and a refetch) every time.
// ❌ BAD: a new list each build → never equal → cache miss every time → refetch loop.
ref.watch(myProvider([1, 2, 3]));
// ✅ GOOD: primitives (String, int, bool, enum) have value equality.
ref.watch(userProvider('123'));
// ✅ For composite args, use a record (value equality!) or an == /hashCode class.
ref.watch(searchProvider((query: 'flutter', page: 2)));
Rule: family arguments should be simple values with value equality —
String,int,enum, or a record (Dart records) / a class with proper==/hashCode(e.g. viafreezed). Never pass a freshly-constructed collection or an identity-equality object. Theriverpod_lintruleprovider_parameterscatches many of these.
Records (Dart Part 7) are the perfect companion here — they give value equality for free, so a multi-parameter family is clean:
final weatherProvider = FutureProvider.family<Weather, ({String city, String units})>(
(ref, args) => repo.fetch(args.city, args.units),
);
// ref.watch(weatherProvider((city: 'London', units: 'metric')))
family + Notifiers (and the code-gen shortcut)
For the mutable providers, families work too. With code generation (@riverpod, a later series), the argument simply becomes a parameter of build — the cleanest form:
@riverpod
class TodoItem extends _$TodoItem {
@override
Future<Todo> build(String id) async { // the family arg is a build() parameter
return ref.watch(todoRepositoryProvider).fetchById(id);
}
Future<void> toggle() async { /* ... uses `id` ... */ }
}
// ref.watch(todoItemProvider('abc'))
The manual (non-codegen) notifier-family syntax is more verbose, which is exactly why families are one of the strongest reasons to adopt code generation later. For functional providers, the manual .family syntax above is clean and what you'll use most in this series.
Pair family with autoDispose
There's a memory hazard with families: each distinct argument creates a new cached instance, and those instances stick around. Scroll through 500 user profiles and you could cache 500 user providers — a leak. The fix is autoDispose (Part 3), which destroys a family instance when nothing is watching it:
// Highly recommended for families: dispose unused instances automatically.
final userProvider = FutureProvider.autoDispose.family<User, String>((ref, id) async {
return ref.watch(userRepositoryProvider).fetchById(id);
});
Strong recommendation: families should almost always be
autoDispose. Without it, every argument you ever pass leaves a cached instance behind. With it, instances are cleaned up when their screen leaves. We coverautoDisposein detail next.
When to use family
| Use family when… | Don't when… |
| --- | --- |
| state depends on a runtime argument (id, query, category) | the state is the same app-wide (just use a plain provider) |
| you need independent instances per argument | you only ever need one instance |
| the argument has value equality (String/int/record) | the argument is a fresh collection/identity object |
Practice Challenges
Challenge 1 — Parameterize a fetch. Write a FutureProvider.family that fetches a Product by int id.
Show solution
final productProvider = FutureProvider.autoDispose.family<Product, int>((ref, id) {
return ref.watch(productRepoProvider).fetchById(id);
});
(autoDispose recommended for families.)
Challenge 2 — Use it. Watch the product with id 42.
Show solution
final productAsync = ref.watch(productProvider(42));
Challenge 3 — Fix the cache bug. Why does ref.watch(searchProvider(['flutter'])) refetch every build, and how do you fix it?
Show solution
A fresh List is created each build; lists use identity equality, so each call is a new cache key → constant cache misses → refetch loop. Fix by passing a value-equality argument: a String (searchProvider('flutter')) or a record/class with proper ==/hashCode.
Challenge 4 — Multi-param family. Write a family parameterized by city and page cleanly.
Show solution
final feedProvider = FutureProvider.autoDispose
.family<Feed, ({String city, int page})>((ref, args) {
return ref.watch(feedRepo).fetch(args.city, args.page);
});
// ref.watch(feedProvider((city: 'NYC', page: 1)))
A record gives value equality for the composite key.
Challenge 5 — Why autoDispose? What goes wrong with a non-autoDispose family over many arguments?
Show solution
Each distinct argument creates a cached instance that persists; over many arguments (e.g. scrolling many profiles) you accumulate unbounded cached providers — a memory leak. autoDispose destroys instances when no longer watched, bounding memory.
Questions to test yourself
Q1 (basic). What does the family modifier do?
Show answer
It turns a provider into a parameterized one — you call it with an argument (provider(arg)) and each argument gets its own independent, cached state. The create function receives the argument as a second parameter.
Q2 (basic). How do you watch a family provider for argument '123'?
Show answer
ref.watch(provider('123')) — call the provider with the argument, then watch the returned provider.
Q3 (intermediate). Why must family arguments have stable ==/hashCode?
Show answer
Because the family caches state keyed by the argument. If the argument's equality is identity-based (e.g. a fresh List or an object without ==), every call is a new key → cache miss → a new provider/refetch each time. Value-equality arguments (String/int/record/freezed class) make caching work.
Q4 (intermediate). Why are records ideal for multi-parameter families?
Show answer
Records have structural value equality for free (Dart Part 7), so a composite key like (city: 'London', page: 2) compares by value — exactly what the family cache needs — without writing ==/hashCode.
Q5 (intermediate). Why should families almost always be autoDispose?
Show answer
Each distinct argument creates a new cached instance that otherwise persists for the app's lifetime. Over many arguments this leaks memory. autoDispose destroys an instance when nothing watches it, keeping memory bounded.
Q6 (advanced). How is a family like a Map, and what are the keys and values?
Show answer
A family behaves like a Map<Arg, ProviderState>: the argument is the key and the provider's state is the value. Calling provider(arg) looks up (or creates) the entry for that key; distinct args are independent entries. This is why argument equality (the map key) is critical, and why unbounded distinct args leak without autoDispose.
Wrapping up
family makes providers parameterized:
Provider.family<State, Arg>((ref, arg) => ...)declares a provider keyed by an argument; the create function takes the arg.- Call it with the argument —
ref.watch(provider(arg))— each argument gets its own independent, cached state (like aMapkeyed by the arg). - Arguments must have value equality (
String/int/enum/record/freezed) — a freshListor identity object breaks caching (refetch loop).riverpod_linthelps. - Pair with
autoDisposeto avoid accumulating cached instances per argument. - Notifier families are cleanest with code generation (
build(arg)).
That autoDispose we kept recommending deserves its own deep dive — it's how Riverpod manages memory. Part 3 covers the autoDispose modifier — memory management in Riverpod.