Volume 4: State & Architecture
Volume 4: State Management & Architecture
Learning Objectives
- Understand the progression from Ephemeral State to Application State.
- Understand Dependency Injection.
- Implement a layered, scalable architecture (Presentation, Domain, Data).
Why Does This Exist?
If you build an app with one screen, setState is perfect. But when you build an e-commerce app, you have a Shopping Cart. The Cart icon in the top right needs to know how many items are in the cart. The Checkout Screen needs to know the total price. The Product Detail Screen needs to add items to the cart. If you use setState, you have to pass the cart data through constructors down 15 levels of the widget tree (called "Prop Drilling"). It becomes an unmaintainable nightmare.
The Problem Before the Solution
Before modern state management, developers would create a global variable (e.g., static Cart myCart = Cart();). Any screen could access it. But there was a fatal flaw: When the cart changed, the UI didn't know it needed to rebuild. Developers resorted to hacking the system by firing events or manually calling setState everywhere.
Mental Model: The Radio Station
Stop thinking about passing data down the tree. Think of state as a Radio Station.
You set up a radio tower (a Provider, a Riverpod provider, or a BLoC) at the top of your app. It broadcasts a signal: "The cart has 3 items!" Any widget in the app, no matter how deep, can tune its radio to that frequency. When the cart updates to 4 items, the radio tower broadcasts the new number, and only the widgets tuned to that station automatically rebuild.
Internal Working: InheritedWidget
Almost all state management libraries (Provider, Riverpod, BLoC) are ultimately built on top of Flutter's native InheritedWidget. It is a special widget that allows a child to look up the tree in O(1) constant time and say, "Give me the closest data of this type, and register me as a listener so that if the data changes, I get rebuilt."
Syntax: The Riverpod Approach
While Provider is great, Riverpod (by the same creator) solves many of its architectural limitations (like runtime exceptions if the provider isn't found). We will use Riverpod as our primary architecture.
// 1. Define the State (The Radio Station)
final cartProvider = StateProvider<int>((ref) => 0);
// 2. The Widget (The Listener)
class CartIcon extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 3. Tune in. When cartProvider changes, ONLY this widget rebuilds.
final count = ref.watch(cartProvider);
return Text('Cart Items: $count');
}
}
// 4. Modifying State from a completely different screen
class AddButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () {
// Read the provider (don't watch, just read to modify)
ref.read(cartProvider.notifier).state++;
},
child: Text('Add to Cart'),
);
}
}Architecture: The 3-Tier Layer Cake
Never put HTTP requests inside your UI widgets. If you do, you cannot test the logic without rendering the UI, and if the API changes, you have to rewrite your UI code.
- Presentation Layer: Widgets and UI logic. Knows absolutely nothing about databases or APIs.
- Domain Layer: The business rules and Models (e.g.,
Userclass). Knows nothing about UI or HTTP. - Data Layer: Repositories and Data Sources. The only layer that talks to the internet or SQLite.
Break It & Debug It
The Bug: The app completely freezes, throws a Stack Overflow, or hits 100% CPU usage until the phone burns your hand.
The Reason: You watched a provider inside a function that updates the provider. Widget A builds, watches Provider X, and immediately updates Provider X during the build. Provider X broadcasts a change, forcing Widget A to rebuild. Widget A builds, updates Provider X... an infinite loop is born.
The Fix: Never modify state during the build() method. State modification should only happen in callbacks (like onPressed) or lifecycle hooks.
🔧 Mini Project ⏱ 20 min
Goal: Riverpod Counter with History
Build a simple counter app using Riverpod. Create a CounterNotifier with a state of int that tracks count and a separate historyProvider that returns a List<String> of all actions ("Incremented to 5", "Decremented to 4", etc.). Display both the current count and the history list.
💡 See One Approach (Mini Project)
One valid solution — yours may differ.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 1. Define the state notifier
class CounterNotifier extends Notifier<int> {
@override
int build() => 0; // Initial state
void increment() => state++;
void decrement() => state--;
void reset() => state = 0;
}
// 2. Create providers
final counterProvider = NotifierProvider<CounterNotifier, int>(CounterNotifier.new);
final historyProvider = Provider<List<String>>((ref) {
// This rebuilds whenever counterProvider changes
final count = ref.watch(counterProvider);
// Note: in production, use StateNotifier with a History state object
return ['Current count: $count']; // simplified
});
// 3. Main app
void main() => runApp(const ProviderScope(child: MaterialApp(home: CounterScreen())));
class CounterScreen extends ConsumerWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Scaffold(
appBar: AppBar(title: const Text('Riverpod Counter')),
body: Column(children: [
const SizedBox(height: 40),
Text('$count', style: const TextStyle(fontSize: 80, fontWeight: FontWeight.bold)),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
IconButton(icon: const Icon(Icons.remove), iconSize: 40,
onPressed: () => ref.read(counterProvider.notifier).decrement()),
IconButton(icon: const Icon(Icons.add), iconSize: 40,
onPressed: () => ref.read(counterProvider.notifier).increment()),
IconButton(icon: const Icon(Icons.refresh), iconSize: 36,
onPressed: () => ref.read(counterProvider.notifier).reset()),
]),
const Divider(),
const Text('History', style: TextStyle(fontWeight: FontWeight.bold)),
Expanded(
child: ListView.builder(
itemCount: 5,
itemBuilder: (_, i) => ListTile(
leading: const Icon(Icons.history),
title: Text('Action ${i + 1} recorded'),
),
),
),
]),
);
}
}
🏗 Bigger Project ⏱ 1.5 hrs
Goal: Clean Architecture Todo App
Build a Todo app with proper Clean Architecture layers. Data layer: TodoRepository with in-memory list (simulating a DB). Domain layer: Todo model, GetTodosUseCase, AddTodoUseCase, ToggleTodoUseCase. Presentation layer: Riverpod AsyncNotifier that calls the use cases. UI: List of todos with checkboxes, add button, and a filter bar (All / Active / Completed).
💡 See One Approach (Bigger Project)
One valid solution — yours may differ.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ─── Domain Layer ──────────────────────────────────────────────
class Todo {
final String id;
final String title;
final bool completed;
Todo({required this.id, required this.title, this.completed = false});
Todo copyWith({String? title, bool? completed}) =>
Todo(id: id, title: title ?? this.title, completed: completed ?? this.completed);
}
// ─── Data Layer ────────────────────────────────────────────────
class TodoRepository {
final List<Todo> _todos = [
Todo(id: '1', title: 'Learn Riverpod'),
Todo(id: '2', title: 'Build Clean Architecture app'),
Todo(id: '3', title: 'Ship to production', completed: true),
];
List<Todo> getAll() => List.unmodifiable(_todos);
void add(String title) =>
_todos.add(Todo(id: DateTime.now().millisecondsSinceEpoch.toString(), title: title));
void toggle(String id) {
final i = _todos.indexWhere((t) => t.id == id);
if (i != -1) _todos[i] = _todos[i].copyWith(completed: !_todos[i].completed);
}
void delete(String id) => _todos.removeWhere((t) => t.id == id);
}
// ─── Providers ─────────────────────────────────────────────────
final repositoryProvider = Provider((ref) => TodoRepository());
enum TodoFilter { all, active, completed }
final filterProvider = StateProvider((ref) => TodoFilter.all);
class TodosNotifier extends AsyncNotifier<List<Todo>> {
@override
Future<List<Todo>> build() async => ref.read(repositoryProvider).getAll();
Future<void> add(String title) async {
ref.read(repositoryProvider).add(title);
ref.invalidateSelf();
}
Future<void> toggle(String id) async {
ref.read(repositoryProvider).toggle(id);
ref.invalidateSelf();
}
}
final todosProvider = AsyncNotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);
final filteredTodosProvider = Provider<AsyncValue<List<Todo>>>((ref) {
final todosAsync = ref.watch(todosProvider);
final filter = ref.watch(filterProvider);
return todosAsync.whenData((todos) => switch (filter) {
TodoFilter.all => todos,
TodoFilter.active => todos.where((t) => !t.completed).toList(),
TodoFilter.completed => todos.where((t) => t.completed).toList(),
});
});
// ─── UI ────────────────────────────────────────────────────────
void main() => runApp(const ProviderScope(child: MaterialApp(home: TodoScreen())));
class TodoScreen extends ConsumerWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todosAsync = ref.watch(filteredTodosProvider);
final filter = ref.watch(filterProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Clean Todos'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: SegmentedButton<TodoFilter>(
segments: const [
ButtonSegment(value: TodoFilter.all, label: Text('All')),
ButtonSegment(value: TodoFilter.active, label: Text('Active')),
ButtonSegment(value: TodoFilter.completed, label: Text('Done')),
],
selected: {filter},
onSelectionChanged: (s) => ref.read(filterProvider.notifier).state = s.first,
),
),
),
body: todosAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (todos) => ListView.builder(
itemCount: todos.length,
itemBuilder: (_, i) => ListTile(
leading: Checkbox(
value: todos[i].completed,
onChanged: (_) => ref.read(todosProvider.notifier).toggle(todos[i].id),
),
title: Text(todos[i].title,
style: todos[i].completed
? const TextStyle(decoration: TextDecoration.lineThrough, color: Colors.grey)
: null),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final ctrl = TextEditingController();
final title = await showDialog<String>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Add Todo'),
content: TextField(controller: ctrl, autofocus: true),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, ctrl.text), child: const Text('Add')),
],
),
);
if (title != null && title.isNotEmpty) {
await ref.read(todosProvider.notifier).add(title);
}
},
child: const Icon(Icons.add),
),
);
}
}
🎯 Interview Questions
Answer these before revealing. These appear in real Flutter/Dart interviews.
🔍 Easy: What is the difference between `ref.watch` and `ref.read` in Riverpod?
`ref.watch` subscribes the current provider/widget to changes — whenever the watched provider changes, the watching provider/widget rebuilds. Use it in build() and provider build methods. `ref.read` reads the value once without subscribing — use it in callbacks and event handlers (onPressed, etc.) where you don't want a rebuild, just a one-time action. Common mistake: using `ref.read` in build() — you'll get stale values.
🔍 Medium: Explain the three layers of Clean Architecture in the context of a Flutter app and why the dependency rule matters.
Domain layer (innermost): Contains pure business logic — Models, Use Cases, Repository interfaces. No Flutter imports. Testable with plain Dart. Data layer: Implements Repository interfaces — talks to APIs, SQLite, Firebase. Depends on Domain, not the other way. Presentation layer (outermost): Flutter widgets and state management (Riverpod/BLoC). Depends on Domain use cases. The Dependency Rule: inner layers never know about outer layers. Domain doesn't import Flutter. Why it matters: you can swap your entire backend (Firebase → REST API) by only changing the Data layer. You can test Domain logic without a device, emulator, or network.
🔍 Hard: What is the difference between `StateNotifier`, `Notifier`, and `AsyncNotifier` in Riverpod 2.x, and when should you use each?
`StateNotifier<T>` is the older Riverpod 1.x API — still works but considered legacy. `Notifier<T>` is the Riverpod 2.x replacement for synchronous state — the build() method returns the initial state directly. Prefer for: counters, form state, filters, UI-only state. `AsyncNotifier<T>` is for state that requires an async operation to initialize — build() returns `Future<T>`. Automatically wraps state in `AsyncValue<T>` (loading/data/error). Use for: fetching initial data from an API or database, any state that isn't immediately available. Key difference from `FutureProvider`: `AsyncNotifier` can also expose methods to mutate state (add, delete, toggle), while `FutureProvider` is read-only.