← Back to blog
Mastering Riverpod: Core Concepts · Part 2 of 8
August 17, 20268 min read

The family Modifier — Parameterized Providers Done Right

RiverpodFlutterDart

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 Map where the argument is the key and the provider's state is the value. userProvider('123') and userProvider('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 equalityString, int, enum, or a record (Dart records) / a class with proper ==/hashCode (e.g. via freezed). Never pass a freshly-constructed collection or an identity-equality object. The riverpod_lint rule provider_parameters catches 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 cover autoDispose in 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 argumentref.watch(provider(arg)) — each argument gets its own independent, cached state (like a Map keyed by the arg).
  • Arguments must have value equality (String/int/enum/record/freezed) — a fresh List or identity object breaks caching (refetch loop). riverpod_lint helps.
  • Pair with autoDispose to 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.