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

SymptomCause
ProviderNotFoundExceptionprovider is below the route or registered as another type
build-time mutation errorload() ran during build
search loses productsfiltered list became mutable source of truth
older result winsconcurrency policy is missing
notifier never disposesownership 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.