Riverpod as used here
Riverpod is the app’s state management and its dependency injection. Every repository, every piece of screen state, the router and the socket are providers. If you understand this page you can follow any feature from the widget to the HTTP call.
The app is on flutter_riverpod 2.6.1 (app/pubspec.lock, resolved from ^2.4.0 in
app/pubspec.yaml:22). It uses the hand-written API, not the @riverpod code generator, and the
Notifier/AsyncNotifier classes, never StateNotifier or ChangeNotifier, as CLAUDE.md
says. One legacy StateProvider does exist; see below.
The mental model
Section titled “The mental model”A provider is a global, lazily created, cached value with a recipe. The recipe can read other providers, and when one of those changes, the recipe re-runs and everyone reading the result is told. So:
- It is a DI container:
ref.read(authRepositoryProvider)asks the container for “the”AuthRepository, and tests swap in a fake by overriding the provider. - It is a spreadsheet of reactive cells:
authStatusProvideris a formula overauthProvider, and the router reacts when the formula’s value changes.
For an Elixir comparison: picture a registry of GenServers that start the first time someone asks for them by name, hold state, can depend on each other, and stop when nobody is subscribed. That gets the lifecycle right. Where it breaks: everything runs on the UI isolate’s single event loop, there are no messages or mailboxes, and a “call” is a plain synchronous method call on the notifier object.
The provider variables themselves (final authProvider = ...) are just immutable keys. The
state lives in a ProviderContainer. In the app there is one, created by the ProviderScope at
the root:
// app/lib/main.dart:15-20runApp( const ProviderScope( observers: [if (kDebugMode) LoggingProviderObserver()], child: FootraceApp(), ),);The observer logs any provider whose recipe throws (app/lib/core/logging.dart:27-43). Tests create
their own containers, which is why globals are safe here: each test gets fresh state.
Every provider kind in the app
Section titled “Every provider kind in the app”There are 44 top-level providers in lib/. By kind:
| Kind | Count | What it holds | Instances |
|---|---|---|---|
Provider<T> |
24 | A value computed once (and recomputed if a watched dependency changes). Used for services and derived values | 10 repositories, dioProvider, appConfigProvider, clockProvider, tokenStoreProvider, userCacheProvider, activeRaceStoreProvider, socketTransportProvider, socketLifecycleProvider, routerProvider, authStatusProvider, unitsFormatterProvider, locationServiceProvider, raceTrackerProvider, permissionPlatformProvider |
NotifierProvider |
3 | Synchronous state with methods | unitsProvider, simGpsProvider, trackingDiagnosticsProvider |
NotifierProvider.autoDispose.family |
1 | Same, one per argument, freed when unused | queueProvider (per race-type slug) |
AsyncNotifierProvider |
7 | State loaded asynchronously, with methods | authProvider, activityProvider, raceTypesProvider, historyProvider, settingsProvider, permissionGateProvider, locationProvider |
AsyncNotifierProvider.autoDispose |
1 | lobbyProvider |
|
AsyncNotifierProvider.family |
1 | raceProvider (per race id) |
|
AsyncNotifierProvider.autoDispose.family |
1 | leaderboardProvider (per slug) |
|
FutureProvider |
1 | A one-shot async value, no methods | pendingRaceProvider |
FutureProvider.autoDispose.family |
3 | opponentProfileProvider, headToHeadProvider, raceResultProvider |
|
StreamProvider |
1 | The latest event of a stream | socketStatusProvider |
StateProvider |
1 | A bare mutable value | leaderboardFilterProvider |
Not used anywhere: StateNotifierProvider, ChangeNotifierProvider, StreamNotifierProvider.
The features page lists which feature owns which provider.
Provider: services and derived values
Section titled “Provider: services and derived values”The simplest kind. The recipe returns a value; Riverpod caches it.
// app/lib/data/repositories/auth_repository.dart:89-90final authRepositoryProvider = Provider<AuthRepository>( (ref) => ApiAuthRepository(ref.watch(dioProvider)));That one line is the whole DI wiring for auth HTTP: the repository depends on dioProvider, which
depends on appConfigProvider and tokenStoreProvider (app/lib/data/api/api_client.dart:95-105).
Nobody calls a constructor by hand anywhere else.
A Provider can also be a pure function of other providers. This one turns the async auth state
into the three-valued status the router needs:
// app/lib/features/auth/auth_notifier.dart:129-134/// The router's view of auth: loading is `unknown`, everything else is the/// state's status.final authStatusProvider = Provider<AuthStatus>((ref) { final auth = ref.watch(authProvider); return auth.value?.status ?? AuthStatus.unknown;});Riverpod only notifies listeners when the result changes (by ==). AuthStatus is an enum, so
refreshing the user’s stats inside authProvider does not re-run the router’s redirect; only a
real sign-in or sign-out does. unitsFormatterProvider works the same way over unitsProvider
(app/lib/core/units.dart:86-88).
Provider<void> is used once, for a side effect that should live as long as someone watches it:
// app/lib/data/socket/socket_service.dart:24-38final socketLifecycleProvider = Provider<void>((ref) { final transport = ref.watch(socketTransportProvider); ref.listen<AuthStatus>(authStatusProvider, (_, status) { switch (status) { case AuthStatus.signedIn: transport .connect() .catchError((Object e) => log.w('Socket connect failed: $e')); case AuthStatus.signedOut: transport.disconnect(); case AuthStatus.unknown: break; } }, fireImmediately: true);});The root widget watches it (app/lib/app/app.dart:15), so it is alive for the whole app and the
socket follows the session. The Phoenix side of that socket is backend/lib/footrace_web/channels/user_socket.ex.
ref.watch, ref.read, ref.listen
Section titled “ref.watch, ref.read, ref.listen”Three verbs, and the codebase is consistent about which goes where:
| Verb | Meaning | Use it in |
|---|---|---|
ref.watch(p) |
Read and subscribe: re-run me when p changes |
A widget’s build; a provider’s recipe |
ref.read(p) |
Read once, no subscription | Event handlers, notifier methods, getters for services |
ref.listen(p, cb) |
Run cb on change, without rebuilding |
Side effects: navigation, snackbars, bridging to non-Riverpod code |
In a widget:
// app/lib/features/race_tab/race_tab.dart:24-35 (trimmed)Widget build(BuildContext context, WidgetRef ref) { final raceTypes = ref.watch(raceTypesProvider); final auth = ref.watch(authProvider).value; final formatter = ref.watch(unitsFormatterProvider); // ... // Killed mid-race? Go straight back to it. ref.listen<AsyncValue<String?>>(pendingRaceProvider, (_, next) { final id = next.value; if (id != null) context.go(AppRoutes.race(id)); });and the pull-to-refresh handler on the same screen uses ref.read(...notifier) because it is a
one-off action, not something to rebuild on (app/lib/features/race_tab/race_tab.dart:57-60).
Inside notifiers, services are always fetched with ref.read through a private getter:
// app/lib/features/auth/auth_notifier.dart:18-21class AuthNotifier extends AsyncNotifier<AuthState> { AuthRepository get _repo => ref.read(authRepositoryProvider); TokenStore get _tokens => ref.read(tokenStoreProvider); UserCache get _cache => ref.read(userCacheProvider);ref.read here means “do not rebuild auth just because the repository provider was rebuilt”.
Since repositories are effectively constant in the running app, that is the right call, and it
avoids rebuild loops.
ref.listen is also the bridge between Riverpod and the router. go_router wants a Flutter
Listenable, so the router provider mirrors authStatusProvider into a ValueNotifier:
// app/lib/app/router.dart:30-34final authListenable = ValueNotifier<AuthStatus>( ref.read(authStatusProvider),);ref.listen(authStatusProvider, (_, next) => authListenable.value = next);ref.onDispose(authListenable.dispose);fireImmediately: true (seen in socketLifecycleProvider above and in UnitsNotifier,
app/lib/core/units.dart:12-15) also calls the callback once with the current value, like an RxJS
BehaviorSubject subscription.
Breaking a dependency cycle with a lazy read
Section titled “Breaking a dependency cycle with a lazy read”dioProvider needs to sign the user out on a 401, which means calling authProvider. But
authProvider depends (through the repositories) on dioProvider. Watching it would be a cycle.
The fix is to read it lazily, inside the callback, at the time of the 401:
// app/lib/data/api/api_client.dart:101-103// Read lazily: the auth notifier depends on this provider, so it must// not be resolved while this provider is being built.onUnauthorized: () => ref.read(authProvider.notifier).sessionExpired(),Notifier and AsyncNotifier
Section titled “Notifier and AsyncNotifier”A notifier is a class that owns a provider’s state and exposes methods to change it. Think of it
as a GenServer’s init plus its handle_calls, or an Android ViewModel with a single
StateFlow.
build()returns the initial state. It is the recipe. Inside it,ref.watchworks as usual.stateis the current value. Assigning to it notifies every listener.- Public methods are the API. Widgets call them through
ref.read(p.notifier).
Notifier<T> has a synchronous build. The units preference is the smallest one:
// app/lib/core/units.dart:9-23class UnitsNotifier extends Notifier<Units> { @override Units build() { ref.listen<AsyncValue<Object?>>(settingsProvider, (_, next) { final value = next.value; if (value != null) state = (value as dynamic).units as Units; }, fireImmediately: true); return Units.metric; }
/// Local override, used by tests and until settings have loaded. void set(Units units) => state = units;}
final unitsProvider = NotifierProvider<UnitsNotifier, Units>(UnitsNotifier.new);AsyncNotifier<T> has Future<T> build(), and its state is an AsyncValue<T>. AuthNotifier
restores the session in build (app/lib/features/auth/auth_notifier.dart:24-45) and then sets
state directly from its methods:
// app/lib/features/auth/auth_notifier.dart:67-71Future<void> signOut() async { await _repo.logout(); await _clearSession(); state = const AsyncValue.data(AuthState.signedOut());}Note the convention in this repo: methods that can fail throw (login, register,
updateName) and let the screen show the error, rather than putting the error into state. That
keeps a failed login from turning the whole session state into an error.
AsyncValue
Section titled “AsyncValue”AsyncValue<T> is a three-way sum type: AsyncData, AsyncLoading, AsyncError, and it can
carry the previous value while loading or erroring. Widgets almost always consume it with when:
// app/lib/features/race_tab/race_tab.dart:61-69 (trimmed)child: raceTypes.when( loading: () => const _Skeleton(), error: (error, _) => MessageState( title: "Couldn't load the distances.", body: error.toString(), action: 'Retry', onAction: () => ref.invalidate(raceTypesProvider), ), data: (types) => ListView(.value gives the data or null (app/lib/features/auth/auth_notifier.dart:133). AsyncValue.guard
runs a future and captures a throw as AsyncError instead of propagating it. RaceTypesNotifier
uses both, plus copyWithPrevious, to keep the list on screen while a refresh is in flight and to
swallow a refresh error when there is something to show:
// app/lib/features/race_tab/race_types_notifier.dart:14-23Future<void> refresh() async { final previous = state.value; state = const AsyncValue<List<RaceType>>.loading().copyWithPrevious(state); state = await AsyncValue.guard( () => ref.read(raceTypeRepositoryProvider).dashboard(), ); if (state.hasError && previous != null) { state = AsyncValue.data(previous); }}The closest comparison is a Kotlin sealed interface UiState { Loading; Success; Error }, with
“keep the stale data” built in.
family: one provider per argument
Section titled “family: one provider per argument”.family makes a provider take a parameter. Each distinct argument gets its own state, cached
separately. The queue is per race type, the race per race id:
// app/lib/features/queue/queue_notifier.dart:266-267final queueProvider = NotifierProvider.autoDispose .family<QueueNotifier, QueueState, String>(QueueNotifier.new);// app/lib/features/race/race_notifier.dart:397-399final raceProvider = AsyncNotifierProvider.family<RaceNotifier, RaceState, String>( RaceNotifier.new);The notifier class then extends a Family variant and gets the argument as arg, both in build
and in its methods: class RaceNotifier extends FamilyAsyncNotifier<RaceState, String>
(app/lib/features/race/race_notifier.dart:113) with Future<RaceState> build(String arg)
(:129). Call sites pass the argument when reading:
ref.watch(raceProvider(widget.raceId)) (app/lib/features/race/race_screen.dart:93).
For a FutureProvider.family the argument is the second parameter of the recipe:
// app/lib/features/race/result_screen.dart:21-29final raceResultProvider = FutureProvider.autoDispose.family<RaceResult, String>((ref, raceId) async { final result = await ref.read(raceRepositoryProvider).results(raceId); if (!result.finished) { final timer = Timer(const Duration(seconds: 3), ref.invalidateSelf); ref.onDispose(timer.cancel); } return result;});That provider polls by invalidating itself every three seconds until the server says the race is
finished. invalidateSelf discards the cached value and re-runs the recipe; ref.onDispose
cancels the timer if the screen goes away first.
autoDispose and onDispose
Section titled “autoDispose and onDispose”By default a provider’s state lives for as long as the container: for the app, forever.
.autoDispose frees it when the last listener goes away, which runs every ref.onDispose
callback. That is the Riverpod version of deinit.
The queue relies on it. Leaving the queue screen drops the last watcher, so the notifier stops
polling and leaves the waiting:<slug> channel without anyone calling a “stop” method:
// app/lib/features/queue/queue_notifier.dart:82-89@overrideQueueState build(String arg) { ref.onDispose(() { _stopPolling(); _leaveChannel(); }); return const QueueIdle();}The Phoenix side of that channel is backend/lib/footrace_web/channels/waiting_channel.ex.
raceProvider is not autoDispose. Its build also registers a teardown that cancels its
subscriptions and leaves the race:<id> channel (app/lib/features/race/race_notifier.dart:130 and
:387-394), but without autoDispose that teardown only runs if the provider is invalidated or
the container is disposed, and nothing in lib/ invalidates it. As far as I can tell from the
code, a finished race’s notifier and channel stay alive until the app process ends. I found no
comment saying whether that is deliberate.
Which providers are autoDispose: queueProvider, lobbyProvider, leaderboardProvider,
opponentProfileProvider, headToHeadProvider, raceResultProvider. Everything else is kept.
FutureProvider and StreamProvider
Section titled “FutureProvider and StreamProvider”Use these when there is no API beyond “give me the value”. pendingRaceProvider checks once, at
first read, whether the app was killed mid-race
(app/lib/features/race/race_notifier.dart:403-421). socketStatusProvider exposes the socket’s
status as an AsyncValue built from an async* generator
(app/lib/data/socket/socket_service.dart:41-45). At d1b602a nothing in lib/ watches
socketStatusProvider; only its test does (app/test/data/socket/socket_service_test.dart:23).
The one StateProvider
Section titled “The one StateProvider”// app/lib/features/leaderboard/leaderboard_notifier.dart:6-7/// Which standings are on screen: null is "All distances".final leaderboardFilterProvider = StateProvider<String?>((_) => null);A StateProvider is a bare mutable cell, written from the widget with
ref.read(leaderboardFilterProvider.notifier).state = slug
(app/lib/features/leaderboard/leaderboard_screen.dart:115). It is not a StateNotifier, so it
does not contradict CLAUDE.md, but it is the older style: Riverpod 3 moves StateProvider to a
legacy.dart import. A Notifier<String?> with a select(String?) method would be the
modern equivalent.
Consumer widgets
Section titled “Consumer widgets”Plain Flutter widgets have no ref. Riverpod supplies two replacements:
ConsumerWidget: aStatelessWidgetwhosebuildalso receives aWidgetRef ref(app/lib/app/app.dart:9-21,app/lib/features/race_tab/race_tab.dart:20-24).ConsumerStatefulWidget+ConsumerState: aStatefulWidgetwhoseStatehas areffield, usable ininitState,disposeand callbacks as well asbuild(app/lib/features/queue/queue_screen.dart:20-36).
A widget’s ref.watch subscriptions are dropped when the widget is disposed. That is what lets
autoDispose providers clean up when you leave a screen.
Overrides: how tests swap everything
Section titled “Overrides: how tests swap everything”Any provider can be overridden in a ProviderScope or ProviderContainer. The shared test
helper overrides every provider that touches the platform or the network:
// app/test/support/fakes.dart:544-548, 572-574 (trimmed)[ appConfigProvider.overrideWithValue( AppConfig(apiBaseUrl: 'http://localhost:4000', devTools: devTools), ), authRepositoryProvider.overrideWithValue(auth ?? FakeAuthRepository()), // ... tokenStoreProvider.overrideWithValue(InMemoryTokenStore(token)), userCacheProvider.overrideWithValue(InMemoryUserCache(cachedUser)), locationProvider.overrideWith(IdleLocationNotifier.new),];overrideWithValue(x)replaces aProvider’s value outright.overrideWith(Factory.new)replaces a notifier provider’s notifier class.IdleLocationNotifiersubclasses the realLocationNotifierand returns a plain state frombuild(app/test/support/fakes.dart:459), so no test asks for GPS permission.
The same list feeds testApp(...), which wraps the real FootraceApp in a ProviderScope
(app/test/support/fakes.dart:485-507), and bare containers for notifier tests:
// app/test/features/queue/queue_socket_test.dart:35-37final c = ProviderContainer( overrides: testOverrides(matchmaking: repo, socket: socket));final sub = c.listen(queueProvider(slug), (_, __) {});The c.listen keeps the autoDispose provider alive for the test. Without a listener it would be
disposed between reads.
One oddity: testOverrides lists socketTransportProvider twice
(app/test/support/fakes.dart:554 and :558). Riverpod 2.6 evidently accepts that, since CI runs
these tests. Only the second entry honours the socket: parameter, so tests that pass a scripted
socket rely on the later override winning. I did not run the suite to confirm.
The testing page and Day 12 cover the harness in full.
A provider graph, end to end
Section titled “A provider graph, end to end”How the auth session reaches the router, the socket and the HTTP client:
flowchart TD cfg[appConfigProvider] --> dio[dioProvider] tok[tokenStoreProvider] --> dio dio --> repo[authRepositoryProvider] repo -. read .-> auth[authProvider<br/>AsyncNotifier] tok -. read .-> auth cache[userCacheProvider] -. read .-> auth auth --> status[authStatusProvider] status -. listen .-> router[routerProvider] status -. listen .-> life[socketLifecycleProvider] cfg --> sock[socketTransportProvider] tok --> sock sock --> life dio -. "read on 401" .-> auth
Solid arrows are ref.watch, dotted ones ref.read or ref.listen.
If you upgrade to Riverpod 3
Section titled “If you upgrade to Riverpod 3”Not urgent, but worth knowing, because tutorials you find may already be on 3.x:
FamilyNotifier,FamilyAsyncNotifierand theAutoDispose...notifier base classes are gone. A family notifier extends plainNotifier/AsyncNotifierand takes the argument in its constructor. Four classes here would change:QueueNotifier,RaceNotifier,LeaderboardNotifierandLobbyNotifier(which extendsAutoDisposeAsyncNotifier).StateProvidermoves topackage:flutter_riverpod/legacy.dart.AsyncValue.valueOrNull(used inapp/lib/features/profile/opponent_profile_screen.dart:91) is replaced byvalue.
Checked against the Riverpod 3.0 migration guide; the app itself has not been tried on 3.x.