| name | flutter-state-management |
| description | Use this skill whenever state management decisions arise in Flutter code — including creating providers, choosing between Notifier/AsyncNotifier/FutureProvider/StreamProvider, handling async data, side effects in widgets, dependency injection, or testing stateful logic. Triggers include "add a provider", "manage state in Flutter", "Riverpod", "ref.watch", "ref.read", "AsyncValue", "how do I share state across screens", or any Flutter code change that involves reactive state. Apply this skill even when the user doesn't say "Riverpod" — it's the default for all Flutter state work in this codebase. |
Flutter State Management with Riverpod
Encodes Riverpod conventions (riverpod v2+ with code generation). The goal is boring, predictable state code that reads top-to-bottom and tests easily.
Use riverpod_generator, not manual providers
// GOOD — annotation-based, type-safe, refactor-friendly
@riverpod
Future<User> currentUser(CurrentUserRef ref) async {
return ref.watch(authRepositoryProvider).getCurrentUser();
}
// AVOID — manual provider declaration
final currentUserProvider = FutureProvider<User>((ref) async { ... });
Why: generated providers give correct types automatically, family parameters are positional and refactorable, and the build_runner output is the single source of truth.
Provider selection rules
| Need | Use |
|---|
| Pure derived value, no async | @riverpod T something(Ref ref) |
| Async value, no mutation | @riverpod Future<T> something(Ref ref) async |
| Mutable state + async ops | @riverpod class FooNotifier extends _$FooNotifier |
| Stream subscription | @riverpod Stream<T> something(Ref ref) |
Default to Notifier/AsyncNotifier (class form) the moment a screen needs to trigger state changes. Don't reach for StateProvider — it's deprecated in spirit even where still present.
AsyncValue handling
Always handle three states explicitly in the UI. No .value!, no silent error swallowing.
final userAsync = ref.watch(currentUserProvider);
return userAsync.when(
data: (user) => UserView(user),
loading: () => const LoadingIndicator(),
error: (e, st) => ErrorView(error: e, onRetry: () => ref.invalidate(currentUserProvider)),
);
For mutations inside a Notifier, use AsyncValue.guard:
Future<void> save(User user) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => repo.save(user));
}
ref.watch vs ref.read vs ref.listen
ref.watch in build and inside other providers. This is the default.
ref.read only inside event handlers (onPressed, onTap), never in build.
ref.listen for side effects (navigation, snackbars, dialogs) that should fire on state change, not on every rebuild.
// Side effect on auth state change
ref.listen<AsyncValue<User?>>(currentUserProvider, (prev, next) {
if (next.value == null && prev?.value != null) {
context.go('/login');
}
});
Dependency injection pattern
Repositories and services are providers too — never instantiate them in widgets.
@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
return AuthRepositoryImpl(
apiClient: ref.watch(apiClientProvider),
tokenStore: ref.watch(tokenStoreProvider),
);
}
This makes overriding in tests a one-liner (ProviderScope(overrides: [...])).
Lifecycle: keepAlive and autoDispose
- Default to
autoDispose (the riverpod_generator default). Don't keep state alive longer than the screen needs it.
- Use
@Riverpod(keepAlive: true) only for app-wide singletons: auth, theme, feature flags, the API client itself.
- For "keep alive while a specific screen is mounted," use
ref.keepAlive() conditionally inside the provider, not the annotation.
What goes in a Notifier vs a Repository
- Repository (in
data/): talks to the API client, maps DTOs↔entities, handles caching. No ref, no Riverpod imports.
- Notifier (in
presentation/): orchestrates repository calls, owns UI state (form values, selection, optimistic updates), exposes mutation methods to the widget.
A widget should never call a repository directly. Always go through a Notifier or a derived provider.
Testing
test('login updates current user', () async {
final container = ProviderContainer(overrides: [
authRepositoryProvider.overrideWithValue(FakeAuthRepository()),
]);
addTearDown(container.dispose);
await container.read(authNotifierProvider.notifier).login('a@b.c', 'pw');
expect(container.read(currentUserProvider).value, isNotNull);
});
Always addTearDown(container.dispose) — leaked containers cause flaky tests.
Anti-patterns to reject
StatefulWidget holding business state — convert to a Notifier.
ref.read inside build (causes stale values).
.value! on AsyncValue (drops loading/error handling).
- Repositories instantiated with
RepoImpl() inside a widget or Notifier — must come from a provider.
- A single god-provider holding the whole app's state.
- Manually written providers when the file already uses
@riverpod codegen elsewhere.