Pick by how the state is produced and whether anyone changes it: Provider for a computed or injected value, FutureProvider or StreamProvider for a read-only async value, Notifier for synchronous state with methods, AsyncNotifier for async state the user also changes.
**Provider: repositories, API clients, values derived from other providers. Read-only, no loading state
FutureProvider / StreamProvider: one fetch or one stream with no mutators, like remote config or a live price; you refresh it by invalidating, not by calling methods
Notifier**: build() returns the initial value and your methods assign state: a cart, a filter set, a wizard step
**AsyncNotifier / StreamNotifier**: the same with an async build(); consumers get an AsyncValue, and mutators wrap their IO in AsyncValue.guard
Legacy: StateProvider, StateNotifierProvider and ChangeNotifierProvider moved to package:flutter_riverpod/legacy.dart in Riverpod 3; new code uses Notifier, and even a single mutable field is a three-line Notifier
Rule of thumb: start with the simplest type that fits and move up the moment you need a method or IO
FlutterSenior
State with Riverpod
Riverpod providers, AsyncNotifier, ref, code generation
Questions closed
0 / 25
- 0Review
- 2Asked often
Questions
watch subscribes and rebuilds, read takes the current value once without subscribing, and listen subscribes but runs a callback instead of rebuilding. Mixing them up is the most common Riverpod bug.
**ref.watch(p)**: in build() of widgets and providers; the widget or provider re-runs whenever p changes
**ref.read(p)**: in callbacks such as onPressed, usually ref.read(p.notifier).someMethod(). Used in build(), it returns the value once and misses every later update
**ref.listen(p, (prev, next) {...}): for side effects: navigation, snackbars, logging. You get the previous value too, and nothing rebuilds
Never watch in a callback**: it subscribes at a random moment, and the subscription outlives the tap
All three give you a WidgetRef. ConsumerWidget is a stateless widget whose build receives ref, ConsumerStatefulWidget exposes ref as a field of its ConsumerState, and Consumer is a builder that limits the rebuild to the subtree it wraps.
**ConsumerWidget**: the default: replace StatelessWidget and add WidgetRef ref to build
**ConsumerStatefulWidget**: when you also need local state, controllers or lifecycle methods; ref works in initState, dispose and callbacks, but ref.watch only in build (use ref.listenManual in initState)
**Consumer(builder: (context, ref, child) => ...)**: inside a large widget that should not rebuild as a whole; pass the static part through child so it is not rebuilt either
ref.invalidate(p) marks the provider stale so it rebuilds, at once if something listens and on the next read otherwise; ref.refresh(p) does the same but rebuilds immediately and returns the new value.
**refresh is invalidate plus read**: use it when you need the result, like onRefresh: () => ref.refresh(todosProvider.future), so the RefreshIndicator spins exactly as long as the fetch; both count as a refresh, so the old value stays inside the AsyncValue and when skips its loading branch by default
**invalidate for fire-and-forget: after a successful mutation or on sign-out; nothing is fetched while nobody listens
ref.invalidateSelf()**: the same from inside a notifier, for example after create() succeeds
AsyncValue<T> is the sealed type that async providers expose: AsyncData, AsyncLoading or AsyncError. Because it is sealed, the compiler makes the UI handle all three, and during a reload it can still carry the previous value.
**Render with a switch**: exhaustive pattern matching on the three subclasses, or the shorter when(data:, error:, loading:)
Match the value first: AsyncValue(:final value?) as the first case keeps old data on screen during a refresh instead of flashing a spinner
Accessors: value is null when there is no data (it was valueOrNull before Riverpod 3), requireValue throws instead, plus hasValue, isLoading and error
**AsyncValue.guard(() async {...})**: runs a future and returns AsyncData or AsyncError, so mutators need no try/catch
**isRefreshing / isReloading**: tell a manual refresh from a rebuild caused by a changed dependency; that difference drives the skipLoadingOn... flags of when
family turns one declaration into a set of providers keyed by an argument: productProvider(42) and productProvider(7) are separate instances with separate cached state. You use it to load by id, filter by category, or keep state per tenant.
The argument is a map key: it needs value equality: int, String, an enum, a record, or a class with == and hashCode. An identity-compared object creates a new provider on every build
One argument in manual syntax: pass a record such as (query: 'cat', page: 1) for several; code generation accepts any number of parameters
Notifiers get it through the constructor: in Riverpod 3 NotifierProvider.family passes the argument to your notifier's constructor, and build() takes none
It is a cache: without .autoDispose every key lives as long as the container, so a family keyed by user id or search text grows without bound
Invalidation: ref.invalidate(productProvider) resets every key, ref.invalidate(productProvider(42)) just one
By default a provider lives as long as its container. .autoDispose (the default with code generation) disposes it once nothing listens to it any more, which is what you want for anything scoped to a screen.
What disposal does: Riverpod runs your ref.onDispose callbacks and drops the state; the next read builds it from scratch
**ref.onDispose: cancel subscriptions, timers and in-flight requests here
ref.keepAlive()**: prevents disposal until you call close() on the KeepAliveLink it returns
**ref.onCancel / ref.onResume: fire when the last listener leaves and when one comes back, which is how you build "keep the cache for five minutes after the screen closes"
Keep it alive when: the work must survive a quick back-and-forth (an upload), or refetching on every visit is too expensive
Skip autoDispose for**: app-wide state such as the session or an API client
riverpod_generator is a build_runner step: you annotate a function or a class with @riverpod, and it writes the provider declaration, choosing the provider type from the signature.
A function becomes a provider: @riverpod Future<User> me(Ref ref) generates meProvider, a FutureProvider because of the return type
A class becomes a notifier: it extends the generated _$Name; a Future or Stream returned from build makes it async
Parameters become a family: any number of positional and named parameters, with no .family boilerplate and no record keys
**autoDispose is the default**: opt out with @Riverpod(keepAlive: true)
Why bother: one syntax instead of picking among a dozen provider classes, typed family arguments, and stateful hot reload of providers
Setup: riverpod_annotation in dependencies, riverpod_generator and build_runner in dev_dependencies, a part 'file.g.dart'; line, then dart run build_runner watch -d
ref.watch(p.select((s) => s.field)) subscribes to one derived value and rebuilds only when that value changes by ==. It narrows a coarse provider down to what the widget actually shows.
Equality decides everything: Riverpod 3 filters every update with ==; a plain class compares by identity, so a new instance always counts as a change. Use freezed, equatable or a hand-written == and hashCode
Select primitives or value objects: selecting a List that is copied on every update rebuilds every time, because lists compare by reference
**On an AsyncValue**: select through it: p.select((s) => s.value?.name) turns loading and error into null
It works in providers too: ref.watch(sessionProvider.select((s) => s.userId)) stops a provider from rebuilding on unrelated fields
**When select is not enough**: split the provider; one object with many unrelated fields is the real problem
Riverpod notifies listeners only when you assign a new state that is not == to the old one. add never assigns at all, and addAgain assigns the same list instance, which is always equal to itself, so both updates are filtered out.
**state.add(item)**: mutates the list in place and never calls the state setter, so nobody is told
**state = state..add(item)**: does call the setter, but the cascade returns the same object, and an object is always == to itself
The fix is a new value: state = [...state, item]; removals through state.where(...).toList(), models through copyWith
Why in-place mutation is worse than "no update": code that already holds the list sees it change under it without a rebuild, so the screen and the data drift apart
Last resort: override updateShouldNotify to return true if the state truly must stay mutable, and accept that equality filtering is gone for every listener
SearchQuery has no == or hashCode, so every build creates a key that matches no existing family member. Riverpod creates a fresh provider, which starts a new request, and when that request completes the rebuild creates yet another key: an endless loop.
Family arguments are cache keys: lookup uses == and hashCode, and a plain class falls back to identity
Why it never shows data: the data rebuilds the widget, the rebuild watches a brand-new key, and a brand-new provider always starts in AsyncLoading
**autoDispose hides a leak: the abandoned instances are disposed, so you see request churn; without it every instance would stay in the container forever
Fix**: use a record (text: text, page: 1), give the class value equality with freezed or equatable, or switch to code generation, which takes the fields as separate parameters
visibleTodosProvider reads the filter with ref.read, which returns the value once and subscribes to nothing. A filter change never re-runs the provider, so the list keeps the old filter until the next todosProvider change rebuilds it.
**Only watch creates a dependency**: ref.read returns the current value and forgets it, so filterProvider is not among the things that invalidate this provider
Why it looks like a lag: the chip itself watches filterProvider and highlights at once; the list catches up only on the next todosProvider change, so the bug reads as a delay rather than a missing subscription
The fix: ref.watch(filterProvider): inside a provider's body or build(), watch every value the result depends on; ref.read belongs in callbacks and notifier methods (the same watch/read/listen rule)
Catch it next time: in a ProviderContainer, listen to visibleTodosProvider, change only the filter and expect the list to change; the buggy version fails it while every test that changes the todos still passes. Also enable riverpod_lint (avoid_ref_read_inside_build), which flags ref.read in build() and in a provider body
With ref.listen in a widget. A side effect must run once per state change, while build() can run many times for the same state, so navigation and snackbars never go into it as plain calls.
Where it goes: ref.listen(p, (prev, next) {...}) inside build() registers a callback without making the widget depend on the value, and Riverpod removes it with the widget
Branch on the transition: compare prev and next, so the effect fires only when something actually changed, such as a fresh error or a completed order
**Keep BuildContext out of the notifier: a notifier that pushes routes is tied to one widget tree and cannot be unit-tested; expose state and let the UI react
One-shot results**: a "saved" flag that stays in the state fires again on the next unrelated change; clear it after reacting, or track the submit with Riverpod 3's experimental Mutation
App-wide effects: sign-out redirects belong in one listener near the root or in the router's redirect, not repeated in every screen
initState runs during the build phase, and a provider changing there would have to notify widgets that this frame has already built, so Riverpod throws instead of rendering a tree with two different truths.
What is banned: assigning state, calling a notifier method or ref.invalidate during build, initState or didChangeDependencies
Reading is fine: ref.read(p) in initState is allowed; only ref.watch is restricted to build
**Future.microtask(...) is the folk fix: it silences the error, but it fires on every mount and hides the real design problem
Let the provider initialise itself**: build() is the initializer, so seed it with a family argument or a ProviderScope override, and no widget has to push anything in
Write on events: onPressed, onChanged or a ref.listen callback all run after the build phase, which is where a write belongs
Await the sources' futures inside the derived provider instead of matching two AsyncValue objects against each other.
**ref.watch(p)**: gives an AsyncValue, the right shape for a widget, which has to render loading and error
**ref.watch(p.future)**: gives a Future, the right shape for another provider, because you can simply await it
It still subscribes: watching .future registers the dependency, so the derived provider re-runs whenever either source changes
Errors need no plumbing: an await rethrows, the exception leaves build(), and Riverpod turns it into AsyncError; with (a, b).wait the error arrives wrapped in a ParallelWaitError, so unwrap it if you match on the original type
**Never ref.read(p.future) here: a read does not subscribe, so the derived value silently goes stale
Parallel or sequential**: (a, b).wait when the two loads are independent, plain awaits in order when the second needs the first's result
The old rows are still inside the AsyncValue; the spinner appears because the update was marked as a reload, and when() renders its loading branch on a reload by default. A hand-written state = const AsyncLoading() is the usual cause.
Riverpod keeps the previous value: a new AsyncLoading inherits the last data, so value still returns the old list
Refresh vs reload: ref.refresh and ref.invalidate produce a refresh (isRefreshing), which when skips by default; a changed dependency or a manual AsyncLoading produces a reload (isReloading), which when shows unless skipLoadingOnReload: true
Do not announce loading for background work: assign the result directly: state = await AsyncValue.guard(...)
Render data first: a switch with AsyncValue(:final value?) as the first case shows rows whenever they exist, and isLoading can drive a thin progress bar on top
**For the RefreshIndicator**: return ref.refresh(p.future) from onRefresh, so the indicator spins exactly as long as the fetch
Create a ProviderContainer with overrides for the dependencies, keep a listener on the provider under test, and drive the real notifier through container.read(p.notifier). Without a listener an autoDispose provider is disposed between your calls, and the next read builds a fresh one.
**ProviderContainer.test(overrides: [...])**: Riverpod 3; it disposes itself when the test ends. On 2.x use ProviderContainer(...) plus addTearDown(container.dispose)
Listen first: container.listen(p, (_, _) {}) stands in for the widget that keeps the state alive in the app
Why it looks random: disposal needs a gap, and any await inside the method provides one; Riverpod 3 then throws UnmountedRefException when the method touches ref again
Async notifiers: await container.read(p.future) before calling methods, so build() has finished
Override the repository, not the notifier: the logic under test stays real; widget tests pass the same overrides: to ProviderScope
Attach a ProviderObserver to the root ProviderScope. Riverpod then reports every provider that is created, updated, disposed or fails, in order, without a line of logging inside the providers themselves.
The hooks: didAddProvider, didUpdateProvider, didDisposeProvider and providerDidFail
Wire it once: ProviderScope(observers: [StateLogger()], child: App()); ProviderContainer takes the same observers: list, so tests can log too
Name your providers: pass name:, or let code generation derive it, otherwise the log is a wall of anonymous entries
Log a diff, then narrow: didUpdateProvider gives you the previous and the new value, and an app-wide log on a busy screen is noise; filter to the two or three providers involved
For a single suspect: listenSelf inside the notifier is cheaper than an observer and sits next to the code that mutates
An override replaces what a provider resolves to inside one container: the root ProviderScope for app-wide swaps and test fakes, a nested ProviderScope for a subtree. The provider stays the same key; only its implementation changes.
**overrideWithValue(v)**: replaces the value with a constant: repositories, config, SharedPreferences loaded in main()
**overrideWith(...)**: replaces the build function or, for notifiers, the class: cartProvider.overrideWith(DemoCart.new)
Root overrides: flavour config, dependencies created asynchronously before runApp, and fakes in widget tests
**Nested scopes need dependencies**: the overridden provider declares dependencies (often const []), and every provider that reads it lists it in its own dependencies; otherwise that provider is resolved at the root and never sees the scoped value; debug builds also assert with "one of its dependencies were overridden but the provider is not". riverpod_lint reports missing entries
The classic use: a currentItemProvider overridden per list row, so row widgets can be const and read their item without a family argument
Not a reset button: remounting a ProviderScope under a new key throws away everything below it; that fits a tenant switch, not clearing one feature
When the screen is popped, the autoDispose provider is disposed, but your await keeps going. The code after it then uses a dead Ref, which in Riverpod 3 throws UnmountedRefException.
Riverpod cancels nothing: a Dart future cannot be cancelled, so the request finishes and its continuation runs anyway
**AsyncValue.guard does not help**: it wraps the call, not the state assignment and the ref use that follow it
**Check ref.mounted after every await**: return early before touching ref or state
**Register real cancellation in ref.onDispose**: Dio's CancelToken, timers, subscriptions; an abandoned request then costs nothing
When the work must finish (an upload, a payment): call ref.keepAlive() at the start and close the link in finally
**Do not just drop autoDispose**: you trade a crash for an unbounded cache and stale screens
The notifier watched the session inside build(), so the token refresh threw the state away and re-ran build() from scratch. The draft was written by a mutator, and re-initialization keeps none of it.
**build() is a recomputation, not a constructor**: every ref.watch in it is a subscription that re-runs it, and the result of the new run replaces the state, including the user's edits
**Only watch triggers it**: ref.read and ref.listen inside build() create no such dependency
Narrow the dependency: ref.watch(sessionProvider.select((s) => s.userId)) re-runs on a real account change, not on every token rotation
Read volatile values at call time: ref.read(sessionProvider).token inside save() instead of watching it in build()
Keep the draft in its own provider: server data in one, the user's edits in another; a reload underneath then becomes a merge decision instead of silent data loss
build() answers "what is this state, given its inputs"; what the user typed is an input of its own, not something to recompute.
Riverpod 3 retries failed providers automatically: by default up to 10 times, with exponential backoff from 200 ms to 6.4 s. A 400 never fixes itself, so the screen keeps re-firing until you tell Riverpod which errors are worth a retry.
It covers anything thrown from a build: FutureProvider, StreamProvider, AsyncNotifier.build(); the network log fills up even if the error UI looks static
Configure it on the scope: ProviderScope(retry: (retryCount, error) => ...) returns the delay before the next attempt, or null to stop; a provider's own retry: wins over the scope
Mutators are not retried: an error inside a notifier method is not a build failure, which is why only the load path repeats
Retry transport failures only: timeouts, connection resets, 5xx, offline; never a 4xx, a parse error or an auth rejection
**Leave ProviderException alone**: an error that came from a dependency arrives wrapped in it, and the default policy skips it because the failing provider retries itself; keep that rule in a custom policy, or one failure multiplies down the graph
Riverpod 3.0 (September 2025) folded the API into plain Notifier and AsyncNotifier, made Ref untyped, and changed runtime behaviour: == filtering everywhere, automatic retry, paused listeners for hidden widgets, and a Ref that throws once disposed.
What stops compiling
Legacy providers: StateProvider, StateNotifierProvider and ChangeNotifierProvider now come from package:flutter_riverpod/legacy.dart
Variant classes are gone: AutoDisposeNotifier, FamilyNotifier and friends become plain Notifier; a family argument moves into the notifier's constructor
**AsyncValue**: sealed now; valueOrNull is renamed to value, and the old throwing value is removed
**Ref**: no type parameter and no generated FooRef; ref.state and ref.listenSelf moved onto the notifier
**ProviderObserver**: callbacks receive a ProviderObserverContext
What compiles but behaves differently
**Updates are filtered by ==**: a new state that equals the old one (a copyWith() that changes nothing, a stream repeating a value) no longer notifies; 2.x compared by identity for most providers
**AsyncValue.value**: now returns null in loading/error instead of throwing, so old .value calls compile but no longer fail loudly
Automatic retry: failed providers rebuild with backoff, so a 4xx can re-fire up to ten times unless you set retry
Paused listeners: widgets under a disabled TickerMode, such as routes covered by the current one, stop receiving updates until they are visible again
**A disposed Ref throws**: UnmountedRefException after an await, so check ref.mounted
Errors from dependencies arrive wrapped in ProviderException, which changes is checks in error handlers
New and experimental
**ProviderContainer.test() for tests, Mutation to track a submit's pending, success and error state, and offline persistence** to cache provider state on disk
How to migrate
Upgrade, fix the compile errors with search-and-replace, then audit the behaviour list (retry policy and the equality of your state classes first) and run the widget tests before shipping
Create the ProviderContainer yourself, use it from plain Dart, and hand that same container to the widget tree with UncontrolledProviderScope. A plain ProviderScope builds its own container, and keeping both is what duplicates the state.
The container is the state: a provider is only a key, so two containers mean two auth notifiers, two sockets, two caches, and writes one side never sees
**No BuildContext needed**: container.read(p) and container.listen(p, cb) are the API outside widgets, and ref inside a provider is a view on the same container
**Bootstrap in main()**: build the container with the overrides that need an await, warm up what the splash screen waits for with await container.read(p.future), then pass it to runApp
A background isolate cannot reach it: it has its own memory, so build a short-lived container there, dispose it, and let the UI pick up what it wrote to storage
Never park a container in a static field: that is the service locator Riverpod exists to remove, and it makes overrides and tests meaningless
The global holds no state. A provider object is an immutable descriptor (a factory plus its modifiers), and the value it describes lives in whichever ProviderContainer reads it.
**Closer to a Key than to a singleton: it has no state field, and nothing is created until a container reads it
One descriptor, many values**: the same counterProvider has one value in the running app, another under a nested ProviderScope, and a fresh one in every test container, so tests cannot leak into each other
The type lives on the variable: ref.watch(counterProvider) is an int at compile time, which is why, unlike with package:provider, there is no provider-not-found failure at runtime
Injection is substitution, not mutation: overrides: replaces the implementation for one container, and nothing has to be put back afterwards
The real risk is behavioural: because the name is reachable everywhere, people start reaching for a container everywhere; keep one per isolate and pass Ref down