--- name: flutter-idioms description: >- Flutter idioms: Riverpod 3 state management, Freezed immutable models, go_router navigation, const widget optimization, and repository patterns. Use when developing, refactoring, or reviewing Flutter mobile applications. --- ## Flutter Idioms and Patterns (Riverpod 3) ### Core Philosophy Flutter is a UI toolkit first — performance is a first-class concern. `const` widgets and immutable data keep the render tree efficient. **Riverpod 3** is the canonical state management solution: compile-safe, testable without `BuildContext`, no implicit global state, and with automatic retry and pause/resume built in. **Code generation is mandatory.** All providers must use `@riverpod` / `@Riverpod(keepAlive: true)` annotations with `riverpod_generator` and `build_runner`. This catches type errors, missing overrides, and broken provider graphs at compile time — bugs are caught before they reach users. **Required dependencies:** ```yaml # pubspec.yaml dependencies: flutter_riverpod: 3.2.1 riverpod_annotation: 4.0.2 dev_dependencies: riverpod_generator: 4.0.3 build_runner: # latest riverpod_lint: # latest ``` > **Scope:** This file covers Flutter/Dart *coding idioms*. For file and folder layout, see `references/project-structure.md`. For test naming, see `testing-strategy.md`. For general error handling principles, see `error-handling-principles.md`. --- ### `const` Constructors — Everywhere Make every widget `const` when possible. `const` widgets are created once and never rebuilt unless their inputs change — this is Flutter's most impactful performance optimization. ```dart // ✅ const constructor — widget is rebuild-safe class TaskCard extends StatelessWidget { const TaskCard({super.key, required this.task}); final Task task; // ... } // Usage — compile-time constant const TaskCard(task: myTask) // ❌ Missing const — rebuilt on every parent rebuild TaskCard(task: myTask) ``` **Rules:** - Every `StatelessWidget` that has no mutable state must have a `const` constructor - Pass `const` keyword at the call site, not just the definition - Lint rule `prefer_const_constructors` must be enabled in `analysis_options.yaml` --- ### Widget Decomposition Large `build` methods are the primary source of performance problems and unmaintainable UI code. 1. **Extract a new widget when a subtree has distinct responsibilities** ```dart // ❌ Everything in one build method @override Widget build(BuildContext context) { return Column(children: [ // 30 lines of header... // 50 lines of list... // 20 lines of footer... ]); } // ✅ Each subtree is a named widget with a const constructor @override Widget build(BuildContext context) { return Column(children: [ const TaskHeader(), const TaskList(), const TaskFooter(), ]); } ``` 2. **Never use builder methods (`_buildHeader()`) as a substitute for extracting widgets** - Builder methods do not benefit from `const` and always rerun on parent rebuild - Extract a proper `StatelessWidget` or `ConsumerWidget` instead 3. **Keep `build` methods under ~30 lines** — if longer, decompose --- ### Immutable Data with `freezed` All domain models must be immutable. Use the `freezed` package for: - Immutable value objects with `copyWith` - Union/sealed types (loading, success, error states) - Generated `==`, `hashCode`, and `toString` ```dart // task/models/task.dart @freezed class Task with _$Task { const factory Task({ required String id, required String title, @Default(TaskStatus.pending) TaskStatus status, DateTime? dueDate, }) = _Task; factory Task.fromJson(Map json) => _$TaskFromJson(json); } // Usage — immutable update via copyWith final updated = task.copyWith(status: TaskStatus.done); // ❌ Never mutate a model directly task.status = TaskStatus.done; // compile error — field is final ``` **Rules:** - All domain models use `@freezed` - Never expose mutable fields on domain models - Run `dart run build_runner build` after changing freezed models --- ### Provider Decision Tree Use this tree to pick the correct provider pattern. **Follow it top-to-bottom — the first match wins.** ``` Does the provider have side-effects (create/update/delete)? ──> YES ──┐ │ │ NO │ │ Is it async? │ │ │ Is it async? YES NO │ │ │ │ YES NO ▼ ▼ │ │ @riverpod @riverpod ▼ ▼ class class @riverpod @riverpod (AsyncNotifier) (Notifier) function function (FutureProvider) (Provider) ``` **Decision summary:** | Has side-effects? | Async? | Pattern | Provider type generated | |---|---|---|---| | No | No | `@riverpod` function | `Provider` | | No | Yes | `@riverpod` async function | `FutureProvider` | | No | Stream | `@riverpod` `Stream` function | `StreamProvider` | | Yes | No | `@riverpod` class | `NotifierProvider` | | Yes | Yes | `@riverpod` class with `Future` build | `AsyncNotifierProvider` | | Yes | Stream | `@riverpod` class with `Stream` build | `StreamNotifierProvider` | --- ### Riverpod — State Management **Riverpod 3 is the only state management solution** used in this project. Do not introduce BLoC, Cubit, Provider (package:provider), or GetX. > For file layout of state/ directories, see `references/project-structure.md`. #### App Entry Point — `ProviderScope` Every Flutter app using Riverpod must wrap the root widget in `ProviderScope`. This creates the `ProviderContainer` that powers all providers in the widget tree. ```dart // main.dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { runApp(const ProviderScope(child: MyApp())); } ``` **Rules:** - Exactly one `ProviderScope` at the app root — never nest `ProviderScope` widgets - Use `overrides:` parameter only in tests (see Testing section) - For retry configuration, pass `retry:` to the root `ProviderScope` (see Runtime Behaviors) #### Provider Definition — Class-Based (Side-Effects) Use class-based providers when the provider needs methods to modify state. ```dart // Synchronous notifier — e.g., a filter or toggle @riverpod class TaskFilter extends _$TaskFilter { @override TaskFilterState build() { return const TaskFilterState(); } void setStatus(TaskStatus? status) { state = state.copyWith(status: status); } void toggleShowCompleted() { state = state.copyWith(showCompleted: !state.showCompleted); } } ``` ```dart // Async notifier — e.g., CRUD operations @riverpod class TaskList extends _$TaskList { @override Future> build() async { return ref.watch(taskRepositoryProvider).getTasks(); } Future addTask(CreateTaskRequest request) async { state = const AsyncLoading(); state = await AsyncValue.guard(() async { final repo = ref.read(taskRepositoryProvider); await repo.createTask(request); // REQUIRED: check ref.mounted after every await if (!ref.mounted) return state.requireValue; return repo.getTasks(); }); } Future deleteTask(String id) async { state = const AsyncLoading(); state = await AsyncValue.guard(() async { final repo = ref.read(taskRepositoryProvider); await repo.deleteTask(id); if (!ref.mounted) return state.requireValue; return repo.getTasks(); }); } } ``` #### Provider Definition — Functional (Read-Only / Computed) Use functional providers for derived values with no side-effects. ```dart // Computed value — filtered task list @riverpod List filteredTasks(Ref ref) { final tasks = ref.watch(taskListProvider).valueOrNull ?? []; final filter = ref.watch(taskFilterProvider); return tasks.where((t) => filter.matches(t)).toList(); } // Async one-shot read — e.g., fetch a single task @riverpod Future taskDetail(Ref ref, String id) async { return ref.watch(taskRepositoryProvider).getById(id); } // Stream — real-time data @riverpod Stream> taskStream(Ref ref) { return ref.watch(taskRepositoryProvider).watchAll(); } ``` **Family providers (parameterized):** When a provider takes extra arguments beyond `Ref`, Riverpod creates a *family* — each unique argument combination gets its own independent provider instance with its own cache and disposal. With code generation (which we mandate), **any number of parameters** are supported, including named, optional, and default values. Constraints: - All parameters must implement `==` and `hashCode` (primitives, `freezed` models, Dart records all work) - Each family member is disposed independently when no listener remains ```dart // ✅ Family with a single key @riverpod Future taskDetail(Ref ref, String id) async { return ref.watch(taskRepositoryProvider).getById(id); } // ✅ Multiple parameters — fully supported with code generation @riverpod Future> projectTasks(Ref ref, String projectId, {TaskStatus? status}) async { return ref.watch(taskRepositoryProvider).getByProject(projectId, status: status); } // Usage: ref.watch(projectTasksProvider('proj-1', status: TaskStatus.done)) ``` #### `ref.watch` vs `ref.read` ```dart // ✅ ref.watch — subscribes to changes, use inside build() or widget build final tasks = ref.watch(taskListProvider); // ✅ ref.read — one-time read, use inside event handlers / notifier actions Future onSubmit() async { await ref.read(taskListProvider.notifier).addTask(request); } // ❌ Never use ref.watch inside async functions or event handlers Future onSubmit() async { final tasks = ref.watch(taskListProvider); // WRONG — causes errors } ``` #### `Ref.mounted` — Mandatory After Awaits Riverpod 3 throws if you interact with a disposed `Ref` or `Notifier`. **Always check `ref.mounted` after any `await` in a notifier.** ```dart Future updateTask(Task task) async { final repo = ref.read(taskRepositoryProvider); await repo.update(task); // REQUIRED: provider may have been disposed during await if (!ref.mounted) return; state = await AsyncValue.guard(() => repo.getTasks()); } ``` #### Auto-Dispose and `keepAlive` ```dart // ✅ autoDispose is the DEFAULT with code generation (@riverpod) // Provider is disposed when no consumers are listening @riverpod Future taskDetail(Ref ref, String id) async { return ref.watch(taskRepositoryProvider).getById(id); } // ✅ Opt into keepAlive explicitly for app-wide, long-lived state @Riverpod(keepAlive: true) class AuthState extends _$AuthState { @override Future build() async { return ref.watch(authRepositoryProvider).getCurrentUser(); } } // ✅ Repositories should also be keepAlive — they hold connection state and // should not be re-initialized every time a screen rebuilds @Riverpod(keepAlive: true) TaskRepository taskRepository(Ref ref) { return TaskRepositoryImpl(apiClient: ref.watch(apiClientProvider)); } // ❌ Do not set keepAlive: false — that is the default // ❌ Do not set keepAlive: true for screen-scoped state ``` #### Repository Interface Pattern All data access goes through an abstract repository interface. This is the Flutter expression of the Testability-First architecture (see `@.agents/rules/architectural-pattern.md`). ```dart // repository/task_repository.dart — Abstract interface (contract) abstract class TaskRepository { Future> getTasks(); Future getById(String id); Future createTask(CreateTaskRequest request); Future deleteTask(String id); } ``` ```dart // repository/task_repository_impl.dart — Production adapter class TaskRepositoryImpl implements TaskRepository { const TaskRepositoryImpl({required this.apiClient}); final ApiClient apiClient; @override Future> getTasks() async { final response = await apiClient.get('/tasks'); return (response.data as List) .map((e) => Task.fromJson(e as Map)) .toList(); } @override Future getById(String id) async { final response = await apiClient.get('/tasks/$id'); return Task.fromJson(response.data as Map); } @override Future createTask(CreateTaskRequest request) async { await apiClient.post('/tasks', data: request.toJson()); } @override Future deleteTask(String id) async { await apiClient.delete('/tasks/$id'); } } ``` ```dart // repository/task_repository_mock.dart — Test adapter class MockTaskRepository implements TaskRepository { final List _tasks = []; @override Future> getTasks() async => List.unmodifiable(_tasks); @override Future getById(String id) async => _tasks.firstWhere((t) => t.id == id); @override Future createTask(CreateTaskRequest request) async { _tasks.add(Task(id: 'mock-id', title: request.title)); } @override Future deleteTask(String id) async { _tasks.removeWhere((t) => t.id == id); } } ``` ```dart // Wiring — provider that the rest of the app depends on @Riverpod(keepAlive: true) TaskRepository taskRepository(Ref ref) { return TaskRepositoryImpl(apiClient: ref.watch(apiClientProvider)); } // In tests, override with mock: // taskRepositoryProvider.overrideWith((_) => MockTaskRepository()) ``` #### ConsumerWidget vs ConsumerStatefulWidget ```dart // ✅ Prefer ConsumerWidget — stateless, simpler class TaskListView extends ConsumerWidget { const TaskListView({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final asyncTasks = ref.watch(taskListProvider); return asyncTasks.when( data: (tasks) => TaskListBody(tasks: tasks), loading: () => const LoadingIndicator(), error: (e, _) => ErrorView(error: e), ); } } // Use ConsumerStatefulWidget only when local widget state + riverpod is needed ``` --- ### Riverpod 3 — Runtime Behaviors #### Automatic Retry Riverpod 3 automatically retries providers that throw, using exponential backoff. This improves resilience against transient network failures. ```dart // ✅ Default behavior — providers auto-retry on failure // No action needed for standard use // ✅ Disable retry for a specific provider when failure is non-transient @Riverpod(retry: null) Future appConfig(Ref ref) async { return ref.watch(configRepositoryProvider).load(); } // ✅ Disable globally via ProviderScope ProviderScope( retry: (_, __) => null, // disable for all providers child: const MyApp(), ) ``` **Rules:** - Leave auto-retry enabled for network/IO operations (transient failures) - Disable for operations where retry is unsafe (non-idempotent writes) or pointless (validation errors) #### Pause / Resume (Out-of-View Providers) Riverpod 3 automatically **pauses** provider listeners when the consuming widget is no longer visible. When the widget becomes visible again, listeners resume. This is automatic — no action needed. Be aware of it when debugging. #### `ProviderException` Wrapping When a provider fails, reading it in Riverpod 3 throws a `ProviderException` wrapping the original error, not the raw exception. ```dart // ✅ Catch ProviderException when reading providers that may fail try { final value = container.read(myProvider); } on ProviderException catch (e) { // e.exception contains the original error // e.provider contains which provider failed } ``` ```dart // ✅ Assert on provider failure in tests expect( () => container.read(myProvider), throwsA(isA()), ); ``` #### State Change Detection (`==`) Riverpod 3 uses the `==` operator (not `identical`) to determine if state changed and rebuilds are needed. This means: - `freezed` models work correctly out of the box (generated `==`) - Custom models **must** implement `==` / `hashCode` or use `freezed` - Override `updateShouldNotify` on a `Notifier` for custom comparison logic --- ### Async Patterns 1. **Always handle all three `AsyncValue` states: data, loading, error** ```dart // ✅ Exhaustive asyncValue.when( data: (data) => DataWidget(data: data), loading: () => const CircularProgressIndicator(), error: (err, stack) => ErrorText(err.toString()), ); ``` 2. **Surfacing notifier errors to UI** — use `when(error:...)` for exhaustive handling; use `hasError` only for conditional checks alongside a separate data display: ```dart // ✅ Exhaustive — covers all states, preferred for full-screen states asyncTasks.when( data: (tasks) => TaskListBody(tasks: tasks), loading: () => const LoadingIndicator(), error: (e, _) => ErrorView(error: e), ); // ✅ Conditional — show inline error banner while keeping stale data visible if (asyncTasks.hasError) { // show snackbar or inline error } final tasks = asyncTasks.valueOrNull ?? const []; ``` 3. **Use safe `AsyncValue` accessors to prevent runtime crashes** ```dart // ✅ Safe — returns null if state is loading or error final tasks = ref.watch(taskListProvider).valueOrNull; // ⚠️ Unsafe — throws StateError if state is not AsyncData // Only use when you have already confirmed the state is loaded final tasks = ref.watch(taskListProvider).requireValue; ``` 4. **Use `AsyncValue.guard` inside notifier actions to wrap async calls** - It catches exceptions and wraps them in `AsyncError` automatically 5. **Use `StreamProvider` for real-time data** — never poll manually with `Timer` 6. **Always check `ref.mounted` after `await`** — see `Ref.mounted` section above 7. **Force a provider to re-fetch with `ref.invalidate`** ```dart // ✅ Invalidate from outside a notifier (e.g., after a form submit in a widget) ref.invalidate(taskListProvider); // The next watch/read will trigger a fresh build() // ✅ Invalidate from inside a notifier after a mutation Future addTask(CreateTaskRequest request) async { final repo = ref.read(taskRepositoryProvider); await repo.createTask(request); if (!ref.mounted) return; ref.invalidateSelf(); // triggers build() to re-run and return fresh list } ``` **Decision: `invalidateSelf()` vs manual state assignment** ``` After a mutation: ├── Need optimistic UI (instant visual update before server confirms)? │ └── YES → Set state manually via AsyncValue.guard │ (e.g., remove item from list immediately, then call API) └── NO → ref.invalidateSelf() (simpler, always correct — re-runs build()) ``` --- ### Error Handling in Flutter/Riverpod Define a typed exception hierarchy using sealed classes (Dart 3+). Map infrastructure exceptions to domain exceptions inside notifier actions. ```dart // core/errors/app_exception.dart — Typed exception hierarchy sealed class AppException implements Exception { const AppException(this.message); final String message; @override String toString() => message; } class NetworkException extends AppException { const NetworkException(super.message, {this.statusCode}); final int? statusCode; } class ValidationException extends AppException { const ValidationException(super.message, {required this.field}); final String field; } class NotFoundException extends AppException { const NotFoundException(super.message); } ``` ```dart // ✅ Map infrastructure exceptions to domain exceptions inside notifier actions Future addTask(CreateTaskRequest request) async { state = const AsyncLoading(); state = await AsyncValue.guard(() async { try { final repo = ref.read(taskRepositoryProvider); await repo.createTask(request); if (!ref.mounted) return state.requireValue; return repo.getTasks(); } on DioException catch (e) { // Transform infrastructure error → domain error throw NetworkException( 'Failed to create task: ${e.message}', statusCode: e.response?.statusCode, ); } }); } ``` ```dart // ✅ Handle typed errors in UI asyncTasks.when( data: (tasks) => TaskListBody(tasks: tasks), loading: () => const LoadingIndicator(), error: (error, _) => switch (error) { NetworkException() => ErrorView(message: 'Network error: ${error.message}'), NotFoundException() => const ErrorView(message: 'Not found'), _ => ErrorView(message: 'Unexpected error: $error'), }, ); ``` **Rules:** - All custom exceptions extend `AppException` (sealed class) - Infrastructure exceptions (`DioException`, `SocketException`) are caught and re-thrown as domain exceptions - Never expose infrastructure types (Dio, HTTP status codes) to the UI layer - Use exhaustive `switch` on the sealed class in error display - See `error-handling-principles.md` for general error handling guidance --- ### Navigation with `go_router` **`go_router` is the canonical navigation library.** ```dart // core/router/app_router.dart @riverpod GoRouter appRouter(Ref ref) { return GoRouter( initialLocation: '/tasks', routes: [ GoRoute(path: '/tasks', builder: (_, __) => const TaskListView()), GoRoute( path: '/tasks/:id', builder: (_, state) => TaskDetailView( // state.pathParameters['id'] is guaranteed non-null by the :id // route pattern — acceptable use of ! in route infrastructure code id: state.pathParameters['id']!, ), ), ], ); } // Navigate — always by path, never by widget reference context.go('/tasks/$taskId'); context.push('/tasks/new'); // push adds to the back stack ``` --- ### Dart Language Idioms 1. **Null safety — use `?.`, `??`, and `??=` idiomatically** ```dart final city = user?.address?.city ?? 'Unknown'; cache ??= await compute(); // assign only if null ``` 2. **Use `late` only for fields initialized before first use that cannot be `final`** - Prefer `final` fields initialized in the constructor - `late` without initialization is an unsafe nullable escape hatch 3. **Extension methods for adding behaviour to types you don't own** ```dart extension TaskStatusLabel on TaskStatus { String get label => switch (this) { TaskStatus.pending => 'Pending', TaskStatus.done => 'Done', }; } ``` 4. **Use `switch` expressions (Dart 3+) for exhaustive pattern matching** ```dart final label = status switch { TaskStatus.pending => 'Pending', TaskStatus.done => 'Done', // Compiler error if a case is missing }; ``` 5. **Avoid `dynamic` — it is the Dart equivalent of TypeScript's `any`** --- ### Testing > Test naming and pyramid proportions are defined in `testing-strategy.md`. This section covers Flutter/Riverpod 3 test patterns. #### Unit Test Providers with `ProviderContainer.test` `ProviderContainer.test` creates an isolated container that **auto-disposes after the test** — no manual `addTearDown` needed. ```dart test('addTask updates state', () async { final container = ProviderContainer.test(overrides: [ taskRepositoryProvider.overrideWith((_) => MockTaskRepository()), ]); await container.read(taskListProvider.notifier).addTask(request); expect(container.read(taskListProvider).value, hasLength(1)); }); ``` #### Override Only the `build` Method with `overrideWithBuild` When you need to control initial state but keep the notifier's methods intact: ```dart test('deleteTask removes item from pre-seeded list', () async { final container = ProviderContainer.test(overrides: [ taskRepositoryProvider.overrideWith((_) => MockTaskRepository()), taskListProvider.overrideWithBuild((ref, notifier) { // Seed initial state — methods (addTask, deleteTask) still work return Future.value([mockTask1, mockTask2]); }), ]); await container.read(taskListProvider.notifier).deleteTask(mockTask1.id); expect(container.read(taskListProvider).value, hasLength(1)); }); ``` #### Widget Tests with `ProviderScope` ```dart testWidgets('shows task list', (tester) async { await tester.pumpWidget(ProviderScope( overrides: [ taskRepositoryProvider.overrideWith((_) => MockTaskRepository()), ], child: const MaterialApp(home: TaskListView()), )); expect(find.byType(TaskCard), findsWidgets); }); ``` #### Use `mockito` with `@GenerateNiceMocks` for Interface Mocks Place the annotation on the library (top of the test file or on `main`) and add a `part` directive for the generated file: ```dart // task_notifier_test.dart @GenerateNiceMocks([MockSpec()]) library; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'task_notifier_test.mocks.dart'; // generated by build_runner void main() { // tests use MockTaskRepository() } ``` --- ### Anti-Patterns — NEVER DO THIS These are common mistakes agents reproduce from Riverpod 2 training data. **Every item below is a hard rule violation.** | ❌ Anti-Pattern | ✅ Correct Pattern | |---|---| | `StateProvider` | `@riverpod` class with `Notifier` | | `StateNotifierProvider` | `@riverpod` class with `Notifier` | | `ChangeNotifierProvider` | `@riverpod` class with `Notifier` | | `import 'package:riverpod/legacy.dart'` | Never import legacy APIs | | Typed ref subclass (`TaskDetailRef ref`) | `Ref ref` — Riverpod 3 uses a single `Ref` type; typed subclasses were a Riverpod 2 codegen artifact that no longer exists | | `ref.watch` inside async/event handler | `ref.read` for one-shot reads in handlers | | Accessing `state`/`ref` after `await` without `ref.mounted` check | Always check `ref.mounted` after `await` | | `ProviderContainer()` + `addTearDown(container.dispose)` | `ProviderContainer.test(overrides: [...])` | | `Timer.periodic` for polling data | `StreamProvider` or `Stream` return | | `keepAlive: false` in annotation | Omit — false is the default | | Manual providers without `@riverpod` annotation | Always use code generation | | Catching raw exceptions from provider reads | Catch `ProviderException` | | `overrideWith((_) => MockNotifier())` when only initial state needs seeding | `overrideWithBuild(...)` — keeps notifier methods intact, only seeds initial state | --- ### Linting and Formatting | Tool | Purpose | Config File | | --- | --- | --- | | `dart format` | Canonical formatting | — (built-in) | | `flutter analyze` | Static analysis + lint | `analysis_options.yaml` | | `riverpod_lint` | Riverpod-specific lint rules | `dev_dependencies` | | `dart pub deps` | Dependency audit | — | | `dart run build_runner build` | Generate provider code | — | **Mandatory `analysis_options.yaml` settings (Dart 3+):** ```yaml analyzer: language: strict-casts: true strict-raw-types: true errors: invalid_assignment: error plugins: - riverpod_lint linter: rules: - prefer_const_constructors - prefer_const_declarations - avoid_dynamic_calls - avoid_print - use_super_parameters ``` **After any code change involving providers, run:** ```bash dart run build_runner build --delete-conflicting-outputs flutter analyze dart format . ``` **During active development, use `watch` mode to avoid re-running manually:** ```bash dart run build_runner watch --delete-conflicting-outputs ``` --- ### Related Principles - Code Idioms and Conventions @code-idioms-and-conventions.md - Project Structure — Flutter Mobile @.agents/skills/flutter-idioms/references/project-structure.md - Security Principles @.agents/rules/security-principles.md - Accessibility Principles @.agents/rules/accessibility-principles.md - Architectural Patterns — Testability-First Design @.agents/rules/architectural-pattern.md - Testing Strategy @testing-strategy.md - Error Handling Principles @error-handling-principles.md - Dependency Management Principles @dependency-management-principles.md