🌙
☀️ Dark

Volume 4: State & Architecture

Intermediate ⏱ 26 min read

Volume 4: State Management & Architecture

Learning Objectives

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.

dart
// 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.

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.

dart
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.

dart
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.

✅ I can architect a Flutter app using Riverpod/BLoC with a clean separation between Presentation, Domain, and Data layers.