Skip to content

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.

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: authStatusProvider is a formula over authProvider, 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-20
runApp(
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.

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.

The simplest kind. The recipe returns a value; Riverpod caches it.

// app/lib/data/repositories/auth_repository.dart:89-90
final 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-38
final 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.

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-21
class 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-34
final 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(),

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.watch works as usual.
  • state is 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-23
class 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-71
Future<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<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-23
Future<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 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-267
final queueProvider = NotifierProvider.autoDispose
.family<QueueNotifier, QueueState, String>(QueueNotifier.new);
// app/lib/features/race/race_notifier.dart:397-399
final 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-29
final 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.

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
@override
QueueState 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.

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).

// 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.

Plain Flutter widgets have no ref. Riverpod supplies two replacements:

  • ConsumerWidget: a StatelessWidget whose build also receives a WidgetRef ref (app/lib/app/app.dart:9-21, app/lib/features/race_tab/race_tab.dart:20-24).
  • ConsumerStatefulWidget + ConsumerState: a StatefulWidget whose State has a ref field, usable in initState, dispose and callbacks as well as build (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.

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 a Provider’s value outright.
  • overrideWith(Factory.new) replaces a notifier provider’s notifier class. IdleLocationNotifier subclasses the real LocationNotifier and returns a plain state from build (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-37
final 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.

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.

Not urgent, but worth knowing, because tutorials you find may already be on 3.x:

  • FamilyNotifier, FamilyAsyncNotifier and the AutoDispose... notifier base classes are gone. A family notifier extends plain Notifier/AsyncNotifier and takes the argument in its constructor. Four classes here would change: QueueNotifier, RaceNotifier, LeaderboardNotifier and LobbyNotifier (which extends AutoDisposeAsyncNotifier).
  • StateProvider moves to package:flutter_riverpod/legacy.dart.
  • AsyncValue.valueOrNull (used in app/lib/features/profile/opponent_profile_screen.dart:91) is replaced by value.

Checked against the Riverpod 3.0 migration guide; the app itself has not been tried on 3.x.

  • Day 5 walks authProvider end to end.
  • Auth flow for what the providers above do on launch, login and a 401.