Capstone: Real-World Flutter Project

State Management with Riverpod Across Features

16 min Lesson 4 of 10

State Management with Riverpod Across Features

In a real-world Flutter application, state rarely belongs to a single screen. A user authentication event ripples through the navigation stack, the profile page, the home feed, and the app bar simultaneously. Riverpod solves this by letting you define providers once and read them from anywhere in the widget tree — without prop drilling, without InheritedWidget boilerplate, and without a single global mutable object.

This lesson wires Riverpod providers directly to the domain layer of our capstone app, models asynchronous data with AsyncValue, and demonstrates how multiple screens share the same slice of state through a single provider reference.

Recap: Provider Anatomy in Riverpod

Every Riverpod provider follows the same contract: it exposes a value and notifies listeners when that value changes. The four provider types you will use most in a feature-rich app are:

  • Provider — synchronous, read-only computed value (e.g. a repository instance)
  • StateNotifierProvider — mutable state managed by a StateNotifier subclass
  • FutureProvider — wraps a Future, exposes AsyncValue<T>
  • StreamProvider — wraps a Stream, exposes AsyncValue<T>
Note: All providers are declared at the top level of a Dart file (outside any class). Riverpod resolves them lazily — a provider is not created until something reads it, and it is disposed when no listener remains (unless you use keepAlive: true or ref.keepAlive()).

Wiring Providers to the Domain Layer

Good architecture separates your UI from your business logic. The domain layer contains use-cases and repository interfaces; Riverpod providers act as the bridge between the domain and the UI. The canonical pattern is:

  1. Declare a repository provider that returns the concrete repository.
  2. Declare a notifier provider whose notifier calls repository methods.
  3. Widgets read the notifier provider and react to AsyncValue states.

Repository provider → StateNotifier → UI

// ── domain/repositories/task_repository.dart ──────────────────
abstract class TaskRepository {
  Future<List<Task>> fetchAll();
  Future<void> create(Task task);
  Future<void> delete(String id);
}

// ── data/repositories/remote_task_repository.dart ─────────────
class RemoteTaskRepository implements TaskRepository {
  final Dio _dio;
  RemoteTaskRepository(this._dio);

  @override
  Future<List<Task>> fetchAll() async {
    final response = await _dio.get('/tasks');
    return (response.data as List)
        .map((json) => Task.fromJson(json as Map<String, dynamic>))
        .toList();
  }

  @override
  Future<void> create(Task task) async {
    await _dio.post('/tasks', data: task.toJson());
  }

  @override
  Future<void> delete(String id) async {
    await _dio.delete('/tasks/$id');
  }
}

// ── providers/task_providers.dart ─────────────────────────────
final dioProvider = Provider<Dio>((ref) => Dio());

final taskRepositoryProvider = Provider<TaskRepository>((ref) {
  return RemoteTaskRepository(ref.watch(dioProvider));
});

// StateNotifier that drives the task list feature
class TaskListNotifier extends StateNotifier<AsyncValue<List<Task>>> {
  final TaskRepository _repository;

  TaskListNotifier(this._repository) : super(const AsyncValue.loading()) {
    load();
  }

  Future<void> load() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() => _repository.fetchAll());
  }

  Future<void> delete(String id) async {
    await _repository.delete(id);
    await load(); // reload after mutation
  }
}

final taskListProvider =
    StateNotifierProvider<TaskListNotifier, AsyncValue<List<Task>>>((ref) {
  return TaskListNotifier(ref.watch(taskRepositoryProvider));
});

AsyncValue: Modeling Async State Safely

AsyncValue<T> is a sealed union with three variants: loading, data, and error. Instead of maintaining three separate booleans (isLoading, hasError, data), you call when() and handle every case in one expression. This makes it impossible to forget the loading or error state at the call site.

Consuming AsyncValue with .when() in a widget

class TaskListScreen extends ConsumerWidget {
  const TaskListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final tasksAsync = ref.watch(taskListProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('My Tasks'),
        actions: [
          // Badge shared across screens — same provider, zero prop drilling
          Consumer(
            builder: (context, ref, _) {
              final count = ref.watch(
                taskListProvider.select(
                  (value) => value.asData?.value.length ?? 0,
                ),
              );
              return Badge(label: Text('$count'));
            },
          ),
        ],
      ),
      body: tasksAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('Error: $err'),
              ElevatedButton(
                onPressed: () =>
                    ref.read(taskListProvider.notifier).load(),
                child: const Text('Retry'),
              ),
            ],
          ),
        ),
        data: (tasks) => ListView.builder(
          itemCount: tasks.length,
          itemBuilder: (context, index) {
            final task = tasks[index];
            return ListTile(
              title: Text(task.title),
              trailing: IconButton(
                icon: const Icon(Icons.delete),
                onPressed: () =>
                    ref.read(taskListProvider.notifier).delete(task.id),
              ),
            );
          },
        ),
      ),
    );
  }
}

Sharing State Between Screens Without Prop Drilling

One of Riverpod's greatest strengths is that any widget in the tree can read any provider without receiving it as a constructor argument. Consider a detail screen that must reflect the same task data as the list screen, and a statistics widget in the dashboard that shows total task count. All three read the same taskListProvider — Riverpod guarantees they share one instance.

Tip: Use ref.watch(provider.select(...)) to subscribe to only a subset of the state. This prevents unnecessary rebuilds when unrelated parts of the state change — critical for smooth 60 fps performance in lists.

Family Providers: Per-Feature State

When each feature needs its own isolated slice of the same notifier type, use the .family modifier. A common example is per-project task lists in a project management app:

Family provider for per-project state

final projectTasksProvider = StateNotifierProvider.family<
    TaskListNotifier,
    AsyncValue<List<Task>>,
    String /* projectId */>((ref, projectId) {
  return TaskListNotifier(
    ref.watch(taskRepositoryProvider),
    projectId: projectId,
  );
});

// In ProjectDetailScreen:
// ref.watch(projectTasksProvider('proj_42')) → isolated state per project
Warning: Do not call ref.read() inside a build() method for reactive state — always use ref.watch(). ref.read() is for one-shot imperative actions (e.g. inside button callbacks). Using ref.read() reactively will silently skip rebuilds and produce stale UI.

Summary

Riverpod's provider graph lets you model the full lifecycle of an async feature — loading, error, and data — through AsyncValue, while keeping domain logic in plain Dart classes that are trivial to unit test. Declaring providers at the top level, wiring them to repository abstractions, and reading them from any widget eliminates prop drilling and keeps each feature's state isolated yet globally accessible when needed.

Key Takeaway: Wire providers to repository interfaces, not concrete implementations. Model all async state with AsyncValue and handle all three variants with .when(). Use ref.watch(provider.select(...)) for fine-grained subscriptions. Leverage .family for per-entity state. These four practices together produce maintainable, testable, and performant feature state in any Flutter app.