0Pricing
Flutter Mobile Development · Lesson

From Provider to Riverpod: Migrating Legacy State

Convert existing ChangeNotifier and Provider code to Riverpod's compile-safe provider graph.

From Provider to Riverpod: Migrating Legacy State is a free Flutter Mobile Development lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Flutter Mobile Development learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Migrate to Riverpod?

The classic provider package solved dependency injection in Flutter, but it has real weaknesses you have probably hit in production:

  • Runtime crashes — calling context.read<T>() for a type that was never provided throws a ProviderNotFoundException at runtime, not compile time.
  • BuildContext coupling — you can only read providers where you have a BuildContext.
  • No combining — depending on one ChangeNotifier from another is awkward and error-prone.

Riverpod is a rewrite by the same author. Providers are top-level globals that the compiler can verify, so a missing dependency becomes a compile error. This lesson walks you through migrating a legacy ChangeNotifier + Provider app to Riverpod 2.0, piece by piece.

The Legacy Code We Are Migrating

Here is a typical legacy counter feature using ChangeNotifier. It exposes a value and a method that calls notifyListeners(). This is the pattern we will convert step by step.

Notice that the state (_count) and the mutation logic live together inside a class that extends ChangeNotifier.

// LEGACY — provider package
import 'package:flutter/foundation.dart';

class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }

  void reset() {
    _count = 0;
    notifyListeners();
  }
}

How the Legacy Model Was Wired Up

In the old setup you register the model with ChangeNotifierProvider high in the widget tree, then read it via context.watch / context.read. The problem: if you forget to register it, the app compiles fine and crashes only when that screen opens.

// LEGACY wiring
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CounterModel(),
      child: const MyApp(),
    ),
  );
}

// In a widget:
final count = context.watch<CounterModel>().count;
context.read<CounterModel>().increment();

Step 1 — Install Riverpod and Add ProviderScope

Add flutter_riverpod to pubspec.yaml. The single most important wiring change is to wrap your app in a ProviderScope. This is the container that stores the state of every provider — it replaces the nest of ChangeNotifierProvider widgets at the root.

  • Old: many provider widgets wrapping MyApp.
  • New: one ProviderScope. Providers themselves are declared as globals, not in the tree.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

Step 2 — ChangeNotifier becomes Notifier

Riverpod 2.0 introduces Notifier as the modern, type-safe replacement for ChangeNotifier. The differences:

  • You hold state in a single state field instead of private fields + getters.
  • You never call notifyListeners() — reassigning state rebuilds listeners automatically.
  • The build() method returns the initial state.

Here is the migrated counter as a Notifier<int>:

import 'package:flutter_riverpod/flutter_riverpod.dart';

class CounterNotifier extends Notifier<int> {
  @override
  int build() => 0; // initial state

  void increment() => state = state + 1;
  void reset() => state = 0;
}

final counterProvider = NotifierProvider<CounterNotifier, int>(
  CounterNotifier.new,
);

Step 3 — Read State in the UI with ConsumerWidget

Replace context.watch/context.read with a WidgetRef. The cleanest path is to make your widget a ConsumerWidget, which adds a ref parameter to build.

  • ref.watch(provider) — subscribe and rebuild on change (use in build).
  • ref.read(provider.notifier) — get the notifier to call methods (use in callbacks).
class CounterScreen extends ConsumerWidget {
  const CounterScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Scaffold(
      body: Center(child: Text('Count: $count')),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(counterProvider.notifier).increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

watch vs read — The Migration Trap

The most common migration bug is using the wrong access method. Map the old API to the new one carefully:

  • context.watch<T>()ref.watch(provider)  (rebuilds the UI)
  • context.read<T>() in a callback → ref.read(provider.notifier)  (no rebuild)

Rule: never call ref.watch inside an onPressed callback — it will not behave as expected and can cause unnecessary rebuilds. Use ref.read for one-off actions, ref.watch for values displayed in the UI.

Step 4 — Migrating Async State (FutureProvider)

Legacy apps often load data inside a ChangeNotifier with manual isLoading/error booleans. Riverpod replaces all of that with AsyncValue and a FutureProvider — loading and error states are modeled for you.

The UI then uses AsyncValue.when to render data, loading, and error branches exhaustively.

final userProvider = FutureProvider<User>((ref) async {
  final repo = ref.watch(userRepositoryProvider);
  return repo.fetchCurrentUser();
});

// In a ConsumerWidget:
final asyncUser = ref.watch(userProvider);
return asyncUser.when(
  data: (user) => Text(user.name),
  loading: () => const CircularProgressIndicator(),
  error: (e, st) => Text('Error: $e'),
);

Step 5 — Combining Providers (No More ProxyProvider)

In the old package, making one model depend on another required ProxyProvider and careful ordering. In Riverpod, a provider simply calls ref.watch on another provider inside its body. Dependencies are explicit, type-safe, and reactive.

Below, a derived provider recomputes automatically whenever cartProvider changes — no manual subscription, no notifyListeners chains.

final cartProvider =
    NotifierProvider<CartNotifier, List<Item>>(CartNotifier.new);

// Derived state — recomputes when the cart changes
final cartTotalProvider = Provider<double>((ref) {
  final items = ref.watch(cartProvider);
  return items.fold(0.0, (sum, item) => sum + item.price);
});

A Standalone Taste of Reactive Derivation in Dart

You do not need Flutter to understand Riverpod's core idea: derived values recompute from their inputs. This pure-Dart snippet models the same fold-the-cart logic used in cartTotalProvider, so you can run and verify the math an online judge would compute.

class Item {
  final String name;
  final double price;
  Item(this.name, this.price);
}

double cartTotal(List<Item> items) =>
    items.fold(0.0, (sum, item) => sum + item.price);

void main() {
  final cart = [
    Item('Coffee', 3.50),
    Item('Bagel', 2.25),
    Item('Juice', 4.00),
  ];
  print('Items: ${cart.length}');
  print('Total: \$${cartTotal(cart).toStringAsFixed(2)}');
}

Step 6 — Incremental Migration Strategy

You rarely rewrite a whole app at once. A safe, incremental plan:

  • Wrap once: add ProviderScope at the root immediately — it coexists with the old provider package.
  • Leaf-first: migrate self-contained features (settings, theme, counters) before tangled ones.
  • Bridge if needed: a Riverpod provider can read legacy data, and a legacy widget can stay until its screen is converted.
  • Convert widgets: change StatelessWidgetConsumerWidget and StatefulWidgetConsumerStatefulWidget as you touch each screen.
  • Delete the old ChangeNotifierProvider and the provider dependency only when nothing references them.

Quick Check — watch vs read

Test your understanding of the most error-prone part of the migration.

Recap — From Provider to Riverpod

You migrated a legacy ChangeNotifier + Provider feature to Riverpod 2.0:

  • Wrapped the app in a single ProviderScope instead of nested provider widgets.
  • Turned ChangeNotifier into a Notifier with a state field and no notifyListeners().
  • Swapped context.watch/read for ref.watch (display) and ref.read(provider.notifier) (actions) inside a ConsumerWidget.
  • Modeled async with FutureProvider + AsyncValue.when, and combined providers via ref.watch instead of ProxyProvider.
  • Followed a leaf-first, incremental path so the two systems coexist during the transition.

The payoff: a compile-safe provider graph where a missing dependency is a build error, not a production crash.

Frequently asked questions

Is the “From Provider to Riverpod: Migrating Legacy State” lesson free?

Yes — the full text of “From Provider to Riverpod: Migrating Legacy State” is free to read here on the web, and the Flutter Mobile Development course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Flutter Mobile Development course, upgrade to CoddyKit PRO.

What will I learn in “From Provider to Riverpod: Migrating Legacy State”?

Convert existing ChangeNotifier and Provider code to Riverpod's compile-safe provider graph. You practise Flutter Mobile Development with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Flutter Mobile Development?

No prior experience is required. Flutter Mobile Development on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “From Provider to Riverpod: Migrating Legacy State” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Flutter Mobile Development lesson?

Yes. Every Flutter Mobile Development lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. From Provider to Riverpod: Migrating Legacy State
  2. Code Generation with riverpod_generator and @riverpod
  3. AsyncNotifier and FutureProvider Data Pipelines
  4. Provider Scoping, Overrides, and ProviderObserver
← Back to Flutter Mobile Development