ces unidirectional data flow, separates concerns, and leverages Dart's analyzer for safety.
Architecture Rationale
- Separation of Concerns: UI widgets are strictly presenters. They consume state and dispatch events but contain no business logic.
- Immutability: State objects must be immutable. Updates result in new instances, enabling efficient change detection and debugging.
- Dependency Injection: State providers declare dependencies explicitly. The framework resolves these at runtime, allowing seamless mocking in tests.
- Async Handling: State managers must handle loading, error, and data states natively without manual boolean flags.
Step-by-Step Implementation
1. Define the Domain Model
Create immutable data classes using freezed or standard Dart records for type safety.
// models/task.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'task.freezed.dart';
@freezed
class Task with _$Task {
const factory Task({
required String id,
required String title,
required bool isCompleted,
required DateTime dueDate,
}) = _Task;
}
2. Implement the Repository
Abstract data sources. This allows swapping local cache for remote API without changing state logic.
// repositories/task_repository.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/task.dart';
abstract class TaskRepository {
Future<List<Task>> fetchTasks();
Future<void> updateTask(Task task);
}
class RemoteTaskRepository implements TaskRepository {
@override
Future<List<Task>> fetchTasks() async {
// Simulate network delay
await Future.delayed(const Duration(seconds: 1));
return [
const Task(id: '1', title: 'Refactor State', isCompleted: false, dueDate: DateTime.now()),
];
}
@override
Future<void> updateTask(Task task) async {
// POST implementation
}
}
final taskRepositoryProvider = Provider<TaskRepository>(
(ref) => RemoteTaskRepository(),
);
3. Create the State Provider
Use AsyncNotifier to handle async operations with built-in loading/error states.
// providers/task_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/task.dart';
import '../repositories/task_repository.dart';
// State class for the notifier
class TaskListState {
final List<Task> tasks;
final String? filter;
const TaskListState({this.tasks = const [], this.filter});
List<Task> get filteredTasks {
if (filter == null || filter!.isEmpty) return tasks;
return tasks.where((t) => t.title.contains(filter!)).toList();
}
}
// AsyncNotifier handles the async fetch
@riverpod
class TaskListController extends _$TaskListController {
@override
Future<TaskListState> build() async {
final repo = ref.watch(taskRepositoryProvider);
final tasks = await repo.fetchTasks();
return TaskListState(tasks: tasks);
}
Future<void> toggleCompletion(String taskId) async {
// Optimistic update
final currentState = state.value;
if (currentState == null) return;
state = AsyncData(
TaskListState(
tasks: currentState.tasks.map((t) {
if (t.id == taskId) return t.copyWith(isCompleted: !t.isCompleted);
return t;
}).toList(),
filter: currentState.filter,
),
);
final repo = ref.read(taskRepositoryProvider);
try {
await repo.updateTask(currentState.tasks.firstWhere((t) => t.id == taskId));
} catch (e) {
// Rollback on error
state = AsyncData(currentState);
// Handle error (e.g., show snackbar via notifier or error provider)
}
}
void updateFilter(String query) {
final currentState = state.value;
if (currentState == null) return;
state = AsyncData(TaskListState(tasks: currentState.tasks, filter: query));
}
}
4. Consume in UI
Widgets watch the provider and rebuild only when relevant data changes.
// screens/task_list_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/task_provider.dart';
class TaskListScreen extends ConsumerWidget {
const TaskListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final taskAsync = ref.watch(taskListControllerProvider);
return Scaffold(
appBar: AppBar(title: const Text('Tasks')),
body: Column(
children: [
TextField(
onChanged: (q) => ref.read(taskListControllerProvider.notifier).updateFilter(q),
decoration: const InputDecoration(labelText: 'Filter'),
),
Expanded(
child: taskAsync.when(
data: (state) => ListView.builder(
itemCount: state.filteredTasks.length,
itemBuilder: (context, index) {
final task = state.filteredTasks[index];
return ListTile(
title: Text(task.title),
trailing: Checkbox(
value: task.isCompleted,
onChanged: (_) => ref
.read(taskListControllerProvider.notifier)
.toggleCompletion(task.id),
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
),
],
),
);
}
}
Architecture Decisions
AsyncNotifier over FutureProvider: AsyncNotifier provides mutable state and methods (toggleCompletion), whereas FutureProvider is read-only. This pattern supports optimistic updates and complex interactions.
freezed for Models: Ensures immutability and value equality, which is critical for when rebuilds and preventing unnecessary UI updates.
- Repository Abstraction: Decouples state logic from data sources. The provider never knows if data comes from Hive, Firebase, or REST, enhancing testability.
- Optimistic Updates: The UI updates immediately while the network request fires in the background. If the request fails, the state rolls back. This pattern maximizes perceived performance.
Pitfall Guide
Production Flutter applications fail due to recurring anti-patterns in state management. Avoid these critical mistakes.
-
Business Logic in Widgets: Placing API calls, data transformation, or validation logic inside build methods or onPressed callbacks. This breaks testability and causes logic duplication across screens. Fix: Move all logic to providers/notifiers. Widgets should only contain layout and event dispatching.
-
Global State for Local UI: Using a global provider for ephemeral state like a bottom sheet visibility or a form field value. This pollutes the global state tree and causes unnecessary rebuilds. Fix: Use StatefulWidget or local ValueNotifier for UI-ephemeral state. Reserve global providers for app-level data.
-
Mutating State Directly: Attempting to modify a list inside a state object without creating a new instance. Flutter's change detection relies on object identity. Fix: Always return a new state object. Use copy constructors or freezed to ensure immutability.
-
Ignoring Provider Disposal: Failing to use ref.onDispose for controllers that hold resources (e.g., stream subscriptions, timers). This leads to memory leaks and background processes running after the widget is removed. Fix: Implement ref.onDispose in providers to clean up resources when the provider is no longer watched.
-
Over-Engineering Simple Screens: Applying complex BLoC or Riverpod patterns to static forms or simple settings screens. This adds boilerplate without benefit. Fix: Use Form widgets with TextEditingController for simple forms. Introduce state management only when state needs to be shared or persisted across navigation.
-
Blocking the Main Thread: Performing heavy computation inside a provider's build or mapEventToState without isolates. This causes jank. Fix: Use compute or Isolate.run for heavy tasks within the state logic.
-
State Leakage via References: Passing mutable objects (like List or Map) between providers and mutating them elsewhere. Fix: Enforce deep immutability. Return unmodifiable views or copies when exposing collections from providers.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup / MVP | Riverpod | Rapid development, low boilerplate, easy to scale later. | Low initial cost, low technical debt. |
| Enterprise / Large Team | BLoC | Strict separation, predictable flow, high testability, onboarding friendly. | Medium initial cost, high maintainability. |
| Real-time IoT / Streams | BLoC or Riverpod | Robust stream handling, backpressure support, lifecycle management. | Medium cost, requires stream expertise. |
| Legacy Migration | Provider β Riverpod | Provider is deprecated for new projects; Riverpod offers drop-in migration path with safety. | High migration cost, eliminates future risk. |
| Simple Form / Static | StatefulWidget | No external dependency needed; local state is sufficient. | Zero cost, minimal complexity. |
Configuration Template
pubspec.yaml Dependencies:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.7
freezed: ^2.4.6
json_serializable: ^6.7.1
main.dart Entry Point:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'screens/task_list_screen.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter State Architecture',
theme: ThemeData(primarySwatch: Colors.blue),
home: const TaskListScreen(),
);
}
}
Quick Start Guide
- Initialize Project: Run
flutter create state_app and add dependencies via flutter pub add flutter_riverpod freezed_annotation. Run flutter pub add --dev build_runner freezed.
- Wrap Application: Import
ProviderScope in main.dart and wrap MaterialApp. This enables the provider container.
- Create First Provider: Define a simple
StateProvider or FutureProvider in a providers directory. Use @riverpod annotation for code generation. Run dart run build_runner watch.
- Consume in Widget: Convert your widget to
ConsumerWidget. Use ref.watch to read state and ref.read to dispatch actions. Run the app and verify state updates trigger UI rebuilds.
This article provides the architectural foundation and operational tactics required to implement Flutter state management that scales. Prioritize compile-time safety, immutability, and separation of concerns to ensure long-term codebase health.