Skip to content

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.

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.

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.

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.

Open app/lib/features/auth/auth_notifier.dart:18-124. The class extends AsyncNotifier<AuthState>. Two things to know about that base class:

  1. build() returns the initial state, and it is async. Until the future completes the provider’s value is AsyncLoading. If it throws, the value is AsyncError. If it returns, it is AsyncData(AuthState).
  2. After that, methods change state by 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:

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

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

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.

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 the AuthNotifier instance itself, so you can call its methods. ref.read(authProvider) would give the AsyncValue<AuthState>. That .notifier distinction comes up all over the app.
  • setState(() { ... }) tells Flutter this State changed and build() must run again. It is the widget-local equivalent of assigning state in a notifier.
  • if (mounted) guards against the widget being gone by the time the await returns. Here that happens on success: the router has already replaced the login screen with the home tab, so mounted is false in finally. Calling setState on an unmounted State throws. Android developers know this bug as “updating a Fragment after onDestroyView”.

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.

1. Run the notifier tests and read one. From footrace-flutter/:

Terminal window
flutter test test/features/auth/auth_notifier_test.dart

Open 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:

Terminal window
flutter test test/features/auth/auth_notifier_test.dart test/app_test.dart

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

  1. Why does AuthNotifier.login let ApiException propagate instead of putting it in state?
  2. What would go wrong if routerProvider used ref.watch(authStatusProvider)?
  3. refreshMe() replaces the stats. Does the router’s redirect run? Why or why not?
  4. What is the difference between ref.read(authProvider) and ref.read(authProvider.notifier)?
  5. 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
  1. 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 shows e.message in FormErrorBanner (app/lib/features/auth/login_screen.dart:46-47).
  2. Every status change would rebuild the provider and create a new GoRouter, which loses the navigation stack. The code builds the router once and drives refreshListenable from ref.listen (app/lib/app/router.dart:30-38).
  3. No. authStatusProvider returns the same AuthStatus.signedIn. Riverpod sees an equal value and does not notify, so the ref.listen callback never fires.
  4. The first is the provider’s value, an AsyncValue<AuthState>. The second is the AuthNotifier object, which you call methods on.
  5. 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.