In Riverpod 3, how does .family give each product id its own state, and why must the argument have a consistent == and hashCode?
answer
- a family is a map
- argument is the key
- new list literal, new key
- records compare by value
- notifier takes it in its constructor
basics
~20 sA family turns one provider definition into a map of independent providers keyed by the argument. Riverpod finds the existing instance by == and hashCode, so an argument without value equality, like a fresh list, creates a new state on every call.
solid answer
~40 s`FutureProvider.autoDispose.family<Product, String>((ref, id) async => ...)` defines a family; `productDetailProvider('p-42')` returns a provider object that is `==` to any other call with the same argument, and each distinct argument gets its own state, listeners and disposal. Riverpod looks the instance up like a map key, so the argument needs stable `==` and `hashCode`: strings, ints, enums, const literals, records and value-equality classes work. A non-const `[1, 2]` or a new object without `==` is a new key each build - you get a refetch on every rebuild and, without autoDispose, a growing pile of states. For a Notifier family in Riverpod 3, the argument arrives through the Notifier's constructor, since `FamilyNotifier` was folded into `Notifier`. Pair families with `autoDispose`.
code
dart · 25 linesimport 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class RelatedProducts extends ConsumerWidget {
const RelatedProducts({super.key, required this.ids});
final List<String> ids;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Bad: a new List literal every build is a new family key
// ref.watch(productsByIdsProvider([...ids]));
// Good: one family instance per id, each keyed by a String
return Column(
children: [
for (final id in ids)
switch (ref.watch(productDetailProvider(id))) {
AsyncData(:final value) => Text(value.name),
_ => const SizedBox.shrink(),
},
],
);
}
}go deeper
Remember that productDetailProvider(id) gives each id its own cached state and that the argument must be a stable value such as a String.
Explain the map analogy, how provider equality uses the argument's ==, and why records or const values are safe keys while fresh lists are not.
Diagnose endless refetch loops caused by unstable family keys and enforce autoDispose on families to keep memory bounded.
Define team rules for family keys - immutable value types only, lint enforced - so caching behaviour stays predictable across features.
## A family is a map of providers The Riverpod docs put it simply: if a normal provider is a variable, a **family** is a `Map`. The argument is the key, and each key has its own independent state. ```dart final productDetailProvider = FutureProvider.autoDispose.family<Product, String>((ref, productId) async { final api = ref.watch(catalogApiProvider); return api.fetchProduct(productId); }); // In a widget final product = ref.watch(productDetailProvider('p-42')); ``` `productDetailProvider('p-42')` and `productDetailProvider('p-43')` are two separate providers: separate fetches, separate `AsyncValue`s, separate listeners, separate disposal. Watching both in one widget is legal and they do not interfere. ## How Riverpod finds the right instance Calling the family returns a provider object whose `==` compares two things: the family it came from, and the **argument** with `==`. The container then looks the instance up like a map key, which also uses `hashCode`. The consequence: | Argument | Stable key? | Why | |---|---|---| | `'p-42'`, `42`, an enum value | yes | value equality built in | | `const ['p-42', 'p-43']` | yes | const literals are canonicalised | | `(id: 'p-42', currency: 'EUR')` | yes | records compare field by field | | a class overriding `==`/`hashCode` | yes | value equality by design | | `['p-42', 'p-43']` built in `build` | no | a new `List` each time; `List ==` is identity | | `() => 'p-42'` | no | a new closure each time | With an unstable key, every rebuild of the widget asks for a *different* provider. The result: a refetch per rebuild, a loading spinner that never settles, and - without `autoDispose` - a new state retained on every build. `riverpod_lint` has a `provider_parameters` rule that warns about non-const literals and closures passed as family arguments. ## Several parameters A family takes exactly one argument. For more than one value, pass a record or a small immutable class with value equality: ```dart final priceProvider = FutureProvider.autoDispose .family<Price, ({String productId, String currency})>((ref, args) async { return ref.watch(catalogApiProvider).price(args.productId, args.currency); }); ref.watch(priceProvider((productId: 'p-42', currency: 'EUR'))); ``` ## Notifier families in Riverpod 3 Riverpod 3 fused `FamilyNotifier` into `Notifier`. A notifier family now takes its argument through the **constructor**, and `build()` has no parameters: ```dart final productEditorProvider = NotifierProvider.autoDispose .family<ProductEditor, ProductDraft, String>(ProductEditor.new); class ProductEditor extends Notifier<ProductDraft> { ProductEditor(this.productId); final String productId; @override ProductDraft build() => ProductDraft.empty(productId); } ``` The three type arguments are the notifier, its state and the argument. ## Why families and autoDispose go together - Every distinct argument creates and keeps a state. - A catalogue app opens hundreds of product ids per session. - Without `autoDispose`, all of them stay in memory until the `ProviderScope` is disposed. The docs therefore call `autoDispose` highly advised for families. If a few recent products should stay warm, add a timed `keepAlive` rather than dropping `autoDispose`. ## Diagnosing an unstable key The symptom of a bad family argument is distinctive, and interviewers like to describe it and ask for the cause: 1. The screen shows a loading spinner that flickers or never settles. 2. Network logs show the same request repeating on every rebuild, often every frame during an animation. 3. With `autoDispose` off, memory grows steadily as each new key leaves a state behind. The fix is almost always to make the argument stable: hoist a list into a `final` field or a `const`, switch to a record, or give the class value equality. Adding `==` and `hashCode` to a class that is later mutated is not a fix; a key must be **immutable** as well as comparable, because changing a field after use makes the same object hash to a different slot. ## What a family does not do - It does not deduplicate by argument *content* unless the argument's own `==` does. - It does not share state between arguments; `productDetailProvider('p-42')` never sees data fetched for `'p-43'`. - It does not limit how many instances exist; lifetime is decided by `autoDispose` and `keepAlive`, per instance.
- You need a family keyed by a filter object with five fields; what do you pass?An immutable value with structural equality: a record such as `(category: c, sort: s, page: p)` or a class with `==` and `hashCode` over all fields, for example via freezed or equatable. Never a mutable object: if a field changes after it is used as a key, the same object no longer finds its state.
- What does watching a family instance cost if the argument changes every few seconds, such as a live search query?Each new query creates a new provider instance and a new fetch; the old instance loses its listener and, with autoDispose, is disposed. That is usually what you want. Debounce the query before it reaches the family, and keep autoDispose on so abandoned queries are freed.
saying these in an interview costs you the question
- A new list literal is a fine family argument because the contents are equal
- All arguments of a family share one state
- A family needs no autoDispose because states are freed on navigation
- In Riverpod 3 a Notifier family receives the argument in build(arg)
- Families accept several positional arguments