Day 5: Riverpod through the auth feature
Today you follow the session from start to finish. On launch the app reads a token, asks /api/me whether it is still good, and decides which screen you see. A login or a 401 later changes that answer and every screen that cares updates on its own. The auth feature is small and touches every Riverpod idea the rest of the app uses, so it is the best place to learn them.
Before you start
Section titled “Before you start”Day 4 walked go_router: the route table, the redirect function, and HomeShell. It treated authStatusProvider as a black box that returns unknown, signedOut or signedIn. Today you open that box. The Riverpod primer is the reference version of what follows. Keep it in another tab if a term is new.
Two Dart things from Day 1 matter here. Future<T> is a promise. async/await work as they do in TypeScript. The ?. and ?? operators are null-aware access and null-coalescing.
The container: ProviderScope in main.dart
Section titled “The container: ProviderScope in main.dart”Open app/lib/main.dart:9-21.
void main() { WidgetsFlutterBinding.ensureInitialized(); installErrorHandlers(); // ... FlutterForegroundTask.initCommunicationPort(); runApp( const ProviderScope( observers: [if (kDebugMode) LoggingProviderObserver()], child: FootraceApp(), ), );}ProviderScope at app/lib/main.dart:16 is the only Riverpod setup the app does. It creates a ProviderContainer, the object that holds the current value of every provider, and puts it above the whole widget tree. Every provider in the app is a global final (for example authProvider at app/lib/features/auth/auth_notifier.dart:126), but the global is only a key. The value lives in the container. That split is why tests can build a fresh container with different values for the same keys, and you will see that at the end of today.
The bridge: think of the container as a DI container whose entries are lazy and reactive. Nothing is built until someone reads it. When a provider’s inputs change, it rebuilds and pushes the new value to whoever is watching. In Elixir terms each provider is a bit like a named process that holds state and notifies subscribers, but there are no processes here. It all runs on one isolate (Dart’s single thread), and “notify” means “schedule a rebuild”.
The observers list (app/lib/main.dart:17) adds LoggingProviderObserver in debug builds. It logs any provider whose build throws (app/lib/core/logging.dart:27-42). The if inside a list literal is Dart’s collection-if. In release builds the list is empty.
FootraceApp (app/lib/app/app.dart:9-21) is a ConsumerWidget. That is a StatelessWidget whose build also gets a WidgetRef ref. It watches two providers: socketLifecycleProvider (Day 7) and routerProvider. Those two watches keep both alive for the life of the app.
The state and the notifier
Section titled “The state and the notifier”AuthState: a value type with named constructors
Section titled “AuthState: a value type with named constructors”Open app/lib/features/auth/auth_state.dart. AuthStatus (lines 4-9) is a plain enum with three values. AuthState (lines 11-39) is an immutable class with a private constructor AuthState._ and three public named constructors:
const AuthState.unknown() : this._(status: AuthStatus.unknown);const AuthState.signedOut() : this._(status: AuthStatus.signedOut);const AuthState.signedIn(User user, {UserStats? stats, bool offline = false}) : this._(/* ... */);This is how Dart does “a sum type with a common shape” when it is not using sealed. Kotlin would write a sealed class with three subclasses. Here it is one class, and the named constructors decide which fields are set. user is nullable and only non-null when signed in. Note offline (line 36): the app can be signed in from the cached user while the server is unreachable. That one flag explains a good part of the code below.
AuthNotifier: an AsyncNotifier
Section titled “AuthNotifier: an AsyncNotifier”Open app/lib/features/auth/auth_notifier.dart:18-124. The class extends AsyncNotifier<AuthState>. Two things to know about that base class:
build()returns the initial state, and it isasync. Until the future completes the provider’s value isAsyncLoading. If it throws, the value isAsyncError. If it returns, it isAsyncData(AuthState).- After that, methods change
stateby assigning to it. Each assignment notifies everyone watching.
AsyncValue<T> is the wrapper type for all three cases. If you have used React Query, it is { isLoading, error, data } as one sealed type. In Kotlin terms it is a sealed interface UiState with Loading, Error and Success. Widgets read it with .when(loading:, error:, data:) (you will see that on Day 8), or with .value when they only care about the data.
Read build() at lines 23-45 slowly. It is the whole “restore session on launch” flow:
@overrideFuture<AuthState> build() async { final token = await _tokens.read(); if (token == null || token.isEmpty) return const AuthState.signedOut();
try { final me = await _repo.me(); await _cache.write(me.user); return AuthState.signedIn(me.user, stats: me.stats); } on ApiException catch (e) { if (e.isUnauthorized) { await _clearSession(); return const AuthState.signedOut(); } final cached = await _cache.read(); if (cached != null) { // ... return AuthState.signedIn(cached, offline: true); } // ... }}No token means signed out without a network call. A 401 from /api/me means the token is dead: wipe it and sign out. Any other failure, such as no network or a 500, falls back to the cached user with offline: true. That is a product decision. A runner opening the app in a park with bad signal still lands on the home tab.
The three getters at lines 19-21 use ref.read, not ref.watch:
AuthRepository get _repo => ref.read(authRepositoryProvider);TokenStore get _tokens => ref.read(tokenStoreProvider);UserCache get _cache => ref.read(userCacheProvider);This is the first rule worth memorising:
| Call | Meaning | Use it in |
|---|---|---|
ref.watch(p) |
read now, and rebuild me when p changes |
build() of providers and widgets |
ref.read(p) |
read now, no subscription | event handlers, methods, one-off lookups |
ref.listen(p, cb) |
call cb on every change, do not rebuild me |
side effects: navigation, connect/disconnect, snackbars |
If build() here used ref.watch(authRepositoryProvider), then swapping the repository would re-run the whole restore flow. Nothing swaps it at runtime, so read is enough. It also avoids a cycle you will meet on Day 6: dioProvider needs authProvider for its 401 handler.
The bridge to Elixir: build() is like init/1 in a GenServer. Each method that assigns state is like a handle_call that returns {:reply, :ok, new_state}. Assigning state is also the broadcast. Watchers get the new value the way Phoenix.PubSub subscribers get a message. The analogy breaks in two places. There is no mailbox, so two calls in flight at once can interleave at every await. And there is no supervisor. If build() throws, the provider sits in AsyncError until something invalidates it.
The methods
Section titled “The methods”login and register (lines 48-65) are thin. They call the repository and then _completeSignIn. Note what they do not do. They do not catch ApiException. The doc comment says so: “Throws [ApiException] on failure; the screen shows the message.” A failed login leaves state untouched (still signedOut), and the error goes to the caller as a thrown exception rather than into AsyncError. This is deliberate. If the error went into state, the router would see an error instead of “signed out” and the gate would have to handle a fourth case.
_completeSignIn (lines 106-118) writes the token, then calls /api/me for the name and stats. If that second call fails, it signs in with the thinner auth.user and offline: true. The comment at line 108 says “Login replies with only id and email”. The backend has moved on. backend/lib/footrace_web/controllers/api/user_session_controller.ex:22 now returns name as well, and so does the fixture app/test/fixtures/login.json. The code still works. Only the comment, and the matching one at app/lib/data/models/user.dart:2-3, are out of date.
sessionExpired (lines 74-78) is the 401 path. It is called from the Dio interceptor, not from a screen (app/lib/data/api/api_client.dart:103, Day 6). The guard on line 75 makes it idempotent. Three requests failing with 401 at once sign you out once.
Both signOut and sessionExpired set state = const AsyncValue.data(AuthState.signedOut()) directly. Assigning state is the only way a notifier publishes a change.
The derived provider
Section titled “The derived provider”The last lines of the file are the most important for the rest of the app (app/lib/features/auth/auth_notifier.dart:126-134):
final authProvider = AsyncNotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
final authStatusProvider = Provider<AuthStatus>((ref) { final auth = ref.watch(authProvider); return auth.value?.status ?? AuthStatus.unknown;});AuthNotifier.new is a constructor tear-off, a function that calls AuthNotifier(). Riverpod calls it when the provider is first read.
authStatusProvider is a derived provider, like a selector in Redux or a computed in Vue. It watches authProvider and collapses AsyncValue<AuthState> into a plain enum. Loading becomes unknown. Riverpod only notifies its listeners when the returned value changes (it compares with ==). So a refreshMe() that swaps in new stats changes authProvider but not authStatusProvider, and the router does not re-run its redirect for nothing.
One subtlety you should know about. In Riverpod 2.6.1, which the lockfile pins, AsyncValue.value on an AsyncError with no earlier data rethrows the error. It does not return null. Check riverpod-2.6.1/lib/src/common.dart:493-498 in your pub cache. So if build() ever threw something other than ApiException (a keychain PlatformException, say), authStatusProvider would throw too. valueOrNull would return null instead. I could not find a path that makes build() throw in practice, so treat this as a thing to know, not a known bug.
Who listens: the router and the screens
Section titled “Who listens: the router and the screens”The router uses ref.listen, not ref.watch
Section titled “The router uses ref.listen, not ref.watch”Open app/lib/app/router.dart:27-40.
final routerProvider = Provider<GoRouter>((ref) { final config = ref.watch(appConfigProvider);
final authListenable = ValueNotifier<AuthStatus>( ref.read(authStatusProvider), ); ref.listen(authStatusProvider, (_, next) => authListenable.value = next); ref.onDispose(authListenable.dispose);
final router = GoRouter( // ... refreshListenable: authListenable, redirect: (context, state) => _redirect(ref.read(authStatusProvider), state.matchedLocation),Why not ref.watch(authStatusProvider)? Because watching would rebuild this provider, and building it creates a new GoRouter. A new router throws away the navigation stack. Instead the provider is built once. A ValueNotifier (Flutter’s simplest observable, roughly a MutableStateFlow or a Swift @Published property) is handed to go_router as refreshListenable. ref.listen copies each new status into it, and go_router re-runs redirect whenever it fires. redirect itself uses ref.read, because it only needs the current value at that moment.
ref.onDispose is the cleanup hook, like the function you return from a React useEffect, or terminate/2.
So the full chain for a login is:
sequenceDiagram
participant L as LoginScreen
participant N as AuthNotifier
participant S as authStatusProvider
participant R as routerProvider
participant G as GoRouter
L->>N: ref.read(authProvider.notifier).login(...)
N->>N: repo.login, tokens.write, repo.me
N->>N: state = AsyncData(signedIn)
N-->>S: rebuild (watch)
S-->>R: listen callback: signedIn
R->>G: authListenable.value = signedIn
G->>G: redirect("/login") returns "/home"
The login screen never navigates. The comment at app/lib/features/auth/login_screen.dart:45 says it: “The router’s auth gate moves us to the home tab.” That is the same shape as a Phoenix on_mount hook that redirects based on current_user. The page does not decide where you go next. The gate does.
The screens: ConsumerStatefulWidget
Section titled “The screens: ConsumerStatefulWidget”Open app/lib/features/auth/login_screen.dart. LoginScreen is a ConsumerStatefulWidget (line 13). A StatefulWidget is split in two. The widget is an immutable config object, and a separate State object lives across rebuilds and holds mutable fields. The Consumer prefix gives that State a ref property. Compare it with a UIKit view controller that owns its text fields, minus viewDidLoad. build() can run many times a second and must not have side effects.
The State holds form plumbing (lines 21-25): a GlobalKey<FormState> to trigger validation, two TextEditingControllers, and two local fields _busy and _error. Those last two are UI-local state and do not belong in Riverpod. Nothing else in the app needs to know the login button is spinning. dispose() (lines 28-32) frees the controllers, the same discipline as removing observers in deinit.
_submit (lines 34-51) is the event handler:
Future<void> _submit() async { if (!_formKey.currentState!.validate()) return; setState(() { _busy = true; _error = null; }); try { await ref.read(authProvider.notifier).login( email: _email.text.trim(), password: _password.text, ); } on ApiException catch (e) { if (mounted) setState(() => _error = e.message); } finally { if (mounted) setState(() => _busy = false); }}ref.read(authProvider.notifier)gives theAuthNotifierinstance itself, so you can call its methods.ref.read(authProvider)would give theAsyncValue<AuthState>. That.notifierdistinction comes up all over the app.setState(() { ... })tells Flutter thisStatechanged andbuild()must run again. It is the widget-local equivalent of assigningstatein a notifier.if (mounted)guards against the widget being gone by the time theawaitreturns. Here that happens on success: the router has already replaced the login screen with the home tab, somountedis false infinally. CallingsetStateon an unmountedStatethrows. Android developers know this bug as “updating a Fragment afteronDestroyView”.
The validators come from app/lib/features/auth/auth_form.dart:8-33. They are plain top-level functions that return an error string or null. Flutter’s TextFormField.validator uses that convention. The comments tie them to the backend’s rules: 12 to 72 characters for a password (line 17), 2 to 32 for a name (line 26). FormErrorBanner (lines 36-70) renders _error above the form and collapses to SizedBox.shrink() when there is none.
RegisterScreen (app/lib/features/auth/register_screen.dart) is the same pattern with four fields. One detail is the confirm-password validator at lines 108-109. It is a closure that reads _password.text when it runs. “Create account” on the login screen (app/lib/features/auth/login_screen.dart:108-112) uses context.push, so register sits on top of login, and back returns to it.
Try it
Section titled “Try it”1. Run the notifier tests and read one. From footrace-flutter/:
flutter test test/features/auth/auth_notifier_test.dartOpen app/test/features/auth/auth_notifier_test.dart:15-25. The helper builds a bare ProviderContainer with testOverrides(...) from app/test/support/fakes.dart:524-575. That function swaps authRepositoryProvider for a FakeAuthRepository and the token and user stores for in-memory versions (app/test/support/fakes.dart:548, 572-573). No widget tree is involved. c.read(authProvider.future) awaits the end of build(). Now read the test at lines 53-61, “server unreachable with a cached user: signed in offline”. Find the branch in build() that it covers.
2. Break the gate and watch a test catch it. In app/lib/features/auth/auth_notifier.dart:133, change the fallback from AuthStatus.unknown to AuthStatus.signedOut. Run:
flutter test test/features/auth/auth_notifier_test.dart test/app_test.dartTwo tests should fail: “authStatusProvider reports unknown while loading” (app/test/features/auth/auth_notifier_test.dart:124) and the splash test in app/test/app_test.dart:39-47, which holds /api/me open with neverAnswers (app/test/support/fakes.dart:453). Then think about what a real user would see on a slow launch with a valid token. The splash would be skipped, the login screen would flash, and then they would land on home. Revert the change.
Optional, needs an emulator: run the app with make run-dev or the flutter run line from the repo’s CLAUDE.md, set a breakpoint on app/lib/app/router.dart:33, sign in, and step from the listener into _redirect.
Check yourself
Section titled “Check yourself”- Why does
AuthNotifier.loginletApiExceptionpropagate instead of putting it instate? - What would go wrong if
routerProviderusedref.watch(authStatusProvider)? refreshMe()replaces the stats. Does the router’sredirectrun? Why or why not?- What is the difference between
ref.read(authProvider)andref.read(authProvider.notifier)? - On launch the token is valid but the phone is offline and nothing is cached. What state does
build()return, and what happens to the token?
Answers
- The router’s gate only understands three statuses. A failed login should leave the session
signedOut, and the error belongs to the form. The screen catches it and showse.messageinFormErrorBanner(app/lib/features/auth/login_screen.dart:46-47). - Every status change would rebuild the provider and create a new
GoRouter, which loses the navigation stack. The code builds the router once and drivesrefreshListenablefromref.listen(app/lib/app/router.dart:30-38). - No.
authStatusProviderreturns the sameAuthStatus.signedIn. Riverpod sees an equal value and does not notify, so theref.listencallback never fires. - The first is the provider’s value, an
AsyncValue<AuthState>. The second is theAuthNotifierobject, which you call methods on. AuthState.signedOut(), and_clearSession()deletes the token (app/lib/features/auth/auth_notifier.dart:42-43). Without a cached user the app has nothing to show, so it starts clean.
Day 6: Dio, models, repositories, storage opens the layer under AuthNotifier: the Dio client, the 401 interceptor that calls sessionExpired, and the stores.