+1 (415) 480-3939

Riverpod 3 in Production: Migrating Off Provider Without a Rewrite

Most of the Flutter codebases we inherit have the same archaeology in them: a layer of setState, a layer of ChangeNotifier plus package:provider, and — somewhere near the newest feature — a handful of Riverpod providers added by whoever read the docs last. It works until two teams touch the same screen, or until someone needs to know why a request fired three times on a cold start.

Riverpod 3 (stable since late 2025) is the version worth standardising on, and package:provider is now explicitly in maintenance mode with Riverpod as the recommended successor. But a big-bang rewrite of state management is one of the least defensible line items you can put in front of a client. This tutorial is the staged migration we actually run on engagements: the two packages coexist, every step is shippable, and nothing forces a UI rewrite.

What changed in Riverpod 3 (the parts that affect a migration)

If you last looked at Riverpod 2, four changes matter for planning:

  1. One notifier hierarchy. StateNotifierProvider and ChangeNotifierProvider are legacy; Notifier and AsyncNotifier (usually generated) are the way forward. Legacy classes still exist behind a package:flutter_riverpod/legacy.dart import, which is exactly what makes an incremental migration possible.
  2. Auto-dispose is the default for code-generated providers. Providers clean up when nothing is listening unless you opt out with @Riverpod(keepAlive: true). This flushes out a lot of accidental cache retention — and a few surprises where you relied on it.
  3. Automatic retry for providers that throw, with exponential backoff, configurable per provider or per container.
  4. Mutations (still experimental at the time of writing — check the changelog before you lean on them) give side-effecting operations like "submit this form" a first-class pending/error/success state instead of a hand-rolled bool _isSubmitting.

Everything below assumes Flutter 3.3x/3.4x with Dart 3.x and code generation switched on. Verify current version constraints on pub.dev before you copy the pubspec.

Step 0: a safety net before you touch state

State migrations break things that no analyzer catches: a provider that used to survive a route pop and now doesn't, a request that fires twice, a form that clears itself. Buy yourself signal first.

  • Widget tests on the screens you're about to migrate. Not golden-perfect ones — tests that pump the screen, act, and assert visible text.
  • A logger observer so you can diff provider lifecycles before and after:
class LogObserver extends ProviderObserver {
  @override
  void didAddProvider(ProviderObserverContext ctx, Object? value) {
    debugPrint('+ ${ctx.provider.name ?? ctx.provider.runtimeType}');
  }

  @override
  void didDisposeProvider(ProviderObserverContext ctx) {
    debugPrint('- ${ctx.provider.name ?? ctx.provider.runtimeType}');
  }

  @override
  void providerDidFail(
    ProviderObserverContext ctx,
    Object error,
    StackTrace stackTrace,
  ) {
    debugPrint('! ${ctx.provider.name}: $error');
  }
}

void main() {
  runApp(
    ProviderScope(observers: [LogObserver()], child: const App()),
  );
}

Observer signatures have shifted between major versions; if the override doesn't line up, let your IDE regenerate the stubs from the installed package rather than trusting a blog post (including this one).

Step 1: install Riverpod alongside Provider

dependencies:
  flutter_riverpod: ^3.0.0
  riverpod_annotation: ^3.0.0
  provider: ^6.1.2        # still there; removed at the end

dev_dependencies:
  build_runner: ^2.4.13
  riverpod_generator: ^3.0.0
  riverpod_lint: ^3.0.0
  custom_lint: ^0.7.0
# analysis_options.yaml
analyzer:
  plugins:
    - custom_lint

riverpod_lint is not optional in my opinion. It catches the two mistakes that cause most "Riverpod is confusing" complaints: reading a provider inside build with ref.read, and mutating state during a build.

Wrap the app in a ProviderScope above your existing MultiProvider. Both trees coexist happily:

runApp(
  ProviderScope(
    child: MultiProvider(
      providers: [ChangeNotifierProvider(create: (_) => CartModel())],
      child: const App(),
    ),
  ),
);

Ship that. It changes no behaviour and unblocks everyone else.

Step 2: migrate leaves first — services and repositories

Start where there is no mutable state: singletons, API clients, repositories. These are the boring providers, and moving them gives every later step a dependency to hang off.

// lib/data/work_order_providers.dart
part 'work_order_providers.g.dart';

@Riverpod(keepAlive: true)
Dio dio(Ref ref) {
  final dio = Dio(BaseOptions(baseUrl: Env.apiBase));
  ref.onDispose(dio.close);
  return dio;
}

@Riverpod(keepAlive: true)
WorkOrderRepository workOrderRepository(Ref ref) =>
    WorkOrderRepository(ref.watch(dioProvider));

Run dart run build_runner watch -d and keep it running while you work.

Widgets still on package:provider can reach these through a bridge — a single Consumer near the top of the old tree that injects the Riverpod-owned instance into the legacy tree:

Consumer(
  builder: (context, ref, _) => Provider<WorkOrderRepository>.value(
    value: ref.watch(workOrderRepositoryProvider),
    child: const LegacySubtree(),
  ),
)

One bridge, deleted at the end. Do not scatter twenty of them.

Step 3: async reads become AsyncNotifier (or a plain future provider)

A ChangeNotifier that loads a list is the most common thing you'll find. The typical version has four fields (isLoading, error, items, hasLoaded) and at least one impossible combination of them.

// BEFORE — package:provider
class WorkOrderModel extends ChangeNotifier {
  bool isLoading = false;
  Object? error;
  List<WorkOrder> items = [];

  Future<void> load() async {
    isLoading = true;
    notifyListeners();
    try {
      items = await repo.fetch();
      error = null;
    } catch (e) {
      error = e;
    }
    isLoading = false;
    notifyListeners();
  }
}
// AFTER — Riverpod 3
@riverpod
class WorkOrderList extends _$WorkOrderList {
  @override
  Future<List<WorkOrder>> build() =>
      ref.watch(workOrderRepositoryProvider).fetch();

  Future<void> refresh() async {
    state = const AsyncLoading<List<WorkOrder>>().copyWithPrevious(state);
    state = await AsyncValue.guard(
      () => ref.read(workOrderRepositoryProvider).fetch(),
    );
  }

  Future<void> close(String id) async {
    final repo = ref.read(workOrderRepositoryProvider);
    await repo.close(id);
    ref.invalidateSelf();          // refetch from the source of truth
    await future;                  // let callers await the refreshed state
  }
}

The UI collapses to one exhaustive switch and keeps the old data visible during a refresh:

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final orders = ref.watch(workOrderListProvider);
    return orders.when(
      skipLoadingOnRefresh: true,
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (e, _) => ErrorRetry(
        message: '$e',
        onRetry: () => ref.invalidate(workOrderListProvider),
      ),
      data: (list) => WorkOrderListView(orders: list),
    );
  }
}

Four boolean fields become one AsyncValue. That single change removes more state bugs than any other step in this migration.

Watch out for auto-dispose

With generated providers, popping the last listener disposes the provider — so pushing a detail route and coming back can refetch. If the refetch is cheap, that's a feature. If it isn't, be explicit:

@riverpod
class WorkOrderList extends _$WorkOrderList {
  @override
  Future<List<WorkOrder>> build() {
    final link = ref.keepAlive();
    final timer = Timer(const Duration(minutes: 5), link.close);
    ref.onDispose(timer.cancel);
    return ref.watch(workOrderRepositoryProvider).fetch();
  }
}

A five-minute cache window, expressed in code instead of in a comment.

Step 4: synchronous UI state becomes Notifier

Filters, selections, wizard steps — the small stuff that used to be setState scattered across a screen:

@riverpod
class OrderFilter extends _$OrderFilter {
  @override
  OrderFilterState build() => const OrderFilterState.initial();

  void setStatus(OrderStatus? status) =>
      state = state.copyWith(status: status);

  void toggleMineOnly() =>
      state = state.copyWith(mineOnly: !state.mineOnly);
}

@riverpod
Future<List<WorkOrder>> filteredOrders(Ref ref) async {
  final filter = ref.watch(orderFilterProvider);
  final all = await ref.watch(workOrderListProvider.future);
  return all.where(filter.matches).toList();
}

Derived state is a provider that watches other providers. No manual invalidation, no listener bookkeeping — and filteredOrders recomputes only when the filter or the list actually changes.

Step 5: parameters instead of singletons per screen

Generated providers take arguments directly, which replaces most of the ProxyProvider gymnastics people write with package:provider:

@riverpod
Future<WorkOrder> workOrder(Ref ref, String id) =>
    ref.watch(workOrderRepositoryProvider).byId(id);

// usage
final order = ref.watch(workOrderProvider('wo_1183'));

Arguments must be stable and comparable (==/hashCode) or you'll create a new provider on every build. Records and value classes are fine; freshly constructed maps and lists are not.

Step 6: side effects — retry, and mutations

Riverpod 3 retries failed providers automatically with backoff. That is usually what you want for reads, and usually not what you want for a payment call. Turn it off where retrying is unsafe:

@Riverpod(retry: null)
Future<Receipt> chargeCard(Ref ref, ChargeRequest req) =>
    ref.watch(paymentsProvider).charge(req);

For submits, mutations (experimental — confirm the API against the version you install) replace the local isSubmitting flag:

@riverpod
class CreateOrder extends _$CreateOrder {
  @override
  void build() {}

  Future<void> call(OrderDraft draft) async {
    await ref.read(workOrderRepositoryProvider).create(draft);
    ref.invalidate(workOrderListProvider);
  }
}

If you prefer to stay on stable APIs, keep the submit state in a small AsyncNotifier<void> — the pattern is the same and the migration path to mutations later is short.

For navigation and snackbars, listen rather than watch, in build:

ref.listen(createOrderProvider, (prev, next) {
  if (next case AsyncError(:final error)) {
    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text('$error')));
  }
});

Step 7: tests that make state bugs reproducible

This is the payoff worth putting in the client report. Providers are overridable, so business logic tests need no widgets at all:

test('closing an order refetches the list', () async {
  final repo = FakeWorkOrderRepository(seed: 3);
  final container = ProviderContainer(
    overrides: [workOrderRepositoryProvider.overrideWithValue(repo)],
  );
  addTearDown(container.dispose);

  expect(await container.read(workOrderListProvider.future), hasLength(3));

  await container.read(workOrderListProvider.notifier).close('wo_1');

  expect(repo.closeCalls, ['wo_1']);
  expect(await container.read(workOrderListProvider.future), hasLength(2));
});

Widget tests use the same overrides through ProviderScope:

await tester.pumpWidget(
  ProviderScope(
    overrides: [workOrderRepositoryProvider.overrideWithValue(repo)],
    child: const MaterialApp(home: WorkOrderScreen()),
  ),
);

No service locator to reset between tests, no GetIt teardown ordering bugs, no HTTP mocking layer for pure logic.

Step 8: delete package:provider

When the last ChangeNotifier is gone: drop provider from the pubspec, remove the bridge Consumer, delete the MultiProvider, and run dart analyze. Then run the observer log from Step 0 once more and compare the create/dispose sequence on a cold start against your before-capture. Requests that now fire twice are the last thing to fix, and they're almost always a provider being watched from two scopes.

A migration order that survives contact with a roadmap

On a typical mid-sized app we sequence it like this, one shippable slice per step:

  1. ProviderScope added, no behaviour change.
  2. Services and repositories migrated; one bridge for legacy widgets.
  3. One high-traffic read screen converted to AsyncNotifier — this is the proof point for the team.
  4. Remaining read screens, cheapest first.
  5. Forms and side effects.
  6. Legacy imports and package:provider removed.

Steps 1–3 usually take a sprint on a codebase of 40–60k lines; the rest scales with screen count and can run alongside feature work. Nobody has to stop shipping, and if the roadmap interrupts you at step 4, the app is still in a coherent state.

Common failure modes

  • ref.read inside build. The widget won't rebuild when state changes. riverpod_lint flags it.
  • Unstable provider arguments. New instance on every rebuild, infinite loop, mystified developer.
  • Business logic in the widget. If your ConsumerWidget is doing arithmetic on provider values, it belongs in a derived provider that you can test.
  • keepAlive: true everywhere as a reflex after the first unwanted refetch. Use the timer link above instead, and only where the refetch is genuinely expensive.
  • Migrating the hardest screen first. Pick a mid-complexity read screen for the pilot so the team has a template that works.

AviaryApps builds and modernises Flutter apps for teams that need senior capacity fast, including state-management migrations run as shippable slices next to your roadmap rather than as a freeze. If you're weighing a Provider-to-Riverpod move, or a broader upgrade, get in touch and we'll walk through your codebase's specific sequencing.