State is information over time
Form fields, authenticated sessions, filters, remote data, and failures have different lifecycles. Before choosing a library, ask who owns the state, who observes it, whether it survives navigation, and whether it is a source value or a derivation.
setState is appropriate for short-lived local state. Trouble begins when distant widgets depend on the same value or asynchronous effects are triggered from build.
Provider and ChangeNotifier
final class CounterController extends ChangeNotifier {
int _value = 0;
int get value => _value;
void increment() {
_value++;
notifyListeners();
}
}
ChangeNotifierProvider(
create: (_) => CounterController(),
child: const CounterPage(),
)
Keep controllers focused, do not expose mutable collections, and avoid notifications for changes the UI does not use. ChangeNotifier is approachable, but large models can accumulate implicit dependencies and broad rebuilds.
Riverpod and explicit dependencies
Riverpod encourages declarative, testable dependencies. Exact APIs change between versions, so follow the official documentation for the version pinned by the project.
final productsProvider =
AsyncNotifierProvider<ProductsController, List<Product>>(
ProductsController.new,
);
final class ProductsController extends AsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() {
return ref.read(getProductsProvider)();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(ref.read(getProductsProvider).call);
}
}
The UI consumes loading, error, and data without initiating a request inside build.
Immutable state for richer flows
When a screen combines paging, filters, and selection, define one immutable value:
final class ProductsState {
const ProductsState({
this.items = const [],
this.filter = '',
this.isLoading = false,
this.failure,
});
final List<Product> items;
final String filter;
final bool isLoading;
final Object? failure;
}
Each transition produces a new state, improving predictability, diagnostics, and tests.
Shortcuts to avoid
- Requests inside
build. - Business state in global variables.
- One provider for the entire application.
- Widgets that understand Dio or Firebase exceptions.
- Notifiers that navigate directly.
- Storing values that can be derived from another source.
Test transitions, not implementation details
Cover initial state, success, known failures, and concurrency. An older request must not overwrite a newer search. Verify disposal during long-running work as well.
Guided practice: make the catalog reactive without hiding dependencies
Continue from Module 4. First deliver the existing controller through Provider. Then implement Riverpod in a study branch, not with both mechanisms active.
Checkpoint 1 — install Provider and define ownership
flutter pub add provider
At the composition root:
void main() {
final dependencies = AppDependencies();
runApp(
ChangeNotifierProvider.value(
value: dependencies.productsController,
child: const SalesApp(),
),
);
}
Import provider.dart and remove controller parameters from the app and page. Use .value because the instance already exists; use create when Provider owns creation and disposal.
Checkpoint 2 — watch only where needed
Do not call watch from initState. Schedule the initial intent:
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<ProductsController>().load();
});
}
In build, obtain final state = context.watch<ProductsController>().state and render the sealed-state switch. Use read for retry actions: watch subscribes, while read performs a one-time lookup.
Checkpoint 3 — keep search derived
Add _query, setQuery, and this getter:
List<Product> get visibleProducts {
final current = state;
if (current is! ProductsLoaded) return const [];
final query = _query.trim().toLowerCase();
if (query.isEmpty) return current.items;
return current.items
.where((product) => product.name.toLowerCase().contains(query))
.toList(growable: false);
}
Add a SearchBar(onChanged: controller.setQuery). Do not maintain another mutable filtered list.
Checkpoint 4 — choose a concurrency policy
This version ignores concurrent refresh:
bool _isLoading = false;
Future<void> load() async {
if (_isLoading) return;
_isLoading = true;
state = const ProductsLoading();
notifyListeners();
try {
state = ProductsLoaded(await _getProducts());
} catch (_) {
state = const ProductsFailure('Products could not be loaded');
} finally {
_isLoading = false;
notifyListeners();
}
}
Test with a Completer<List<Product>>: call load twice before completion and verify one repository call. Ignore, cancel, queue, and parallelize are different policies.
Checkpoint 5 — implement Riverpod in isolation
In a study branch:
flutter pub add flutter_riverpod
Wrap the app in ProviderScope and declare:
final productRepositoryProvider = Provider<ProductRepository>((ref) {
throw UnimplementedError('Override at the composition root');
});
final getProductsProvider = Provider<GetProducts>((ref) {
return GetProducts(ref.watch(productRepositoryProvider));
});
final productsProvider =
AsyncNotifierProvider<ProductsNotifier, List<Product>>(
ProductsNotifier.new,
);
final class ProductsNotifier extends AsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() => ref.read(getProductsProvider)();
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(ref.read(getProductsProvider).call);
}
}
A ConsumerWidget watches productsProvider and reads its notifier for actions. Tests use ProviderContainer overrides. Compare ownership, context dependency, async conventions, team familiarity, and test cost before choosing.
Checkpoint 6 — prove transitions and disposal
Test initial, loading, success, empty, failure, concurrent calls, and behavior after disposal. Run formatting, analysis, and tests, then record the chosen approach in the README.
Common errors
| Symptom | Cause |
|---|---|
ProviderNotFoundException | provider is below the route or registered as another type |
| build-time mutation error | load() ran during build |
| search loses products | filtered list became mutable source of truth |
| older result wins | concurrency policy is missing |
| notifier never disposes | ownership and scope are undefined |
Independent practice: implement “latest search wins” with a request number and test an older response arriving last.
Deeper understanding: ownership and lifecycle
Before choosing a library, classify state: ephemeral UI, form state, remote data, session, or derived state. The smallest owner capable of serving all consumers is usually the safest boundary. Global state for convenience increases coupling and widens each change’s impact.
Define when state is born, who can change it, and when it is disposed. A stream subscription, timer, or request that outlives its consumer causes leaks, late updates, or context errors. Cancellation and dispose are behavior, not only cleanup.
Model concurrency explicitly: ignore a second action, cancel the previous one, queue, or allow parallel work are different choices. A test should prove the selected policy.
Terms to review: source of truth, derived state, immutable state, notifier, provider scope, disposal, and race condition. See the course reference.