State Management

I Migrated 5 Flutter Apps from Riverpod 2 to 3 — Every Gotcha I Hit

Muhammad Shakil Muhammad Shakil
Mar 19, 2026
24 min read
Migrating Flutter apps from Riverpod 2 to Riverpod 3 — practical gotchas and real metrics
Back to Blog

I kept postponing the Riverpod 3 migration for months. Five production apps, hundreds of providers between them, and Remi's changelog that made it sound like "just update the package." It is never "just update the package." After migrating all five — a fintech dashboard, two e-commerce apps, a logistics tracker, and an internal admin panel — I have a clear picture of what the migration actually looks like. Not the docs version. The "it's 2 AM and your tests are failing for reasons you don't understand" version.

If you want the full API reference for every Riverpod 3 change, read my complete Riverpod 3 migration guide. This article is different — it's the practical playbook: real time estimates, the gotchas that aren't in the docs, automation scripts that saved me hours, and before/after metrics from actual production codebases.

Why I Waited 6 Months Before Migrating

When Riverpod 3 dropped, my first instinct was to upgrade immediately. Then I remembered the Riverpod 1→2 migration that broke our fintech app's authentication flow in production. That experience taught me a rule: never migrate state management on a major version within the first three months.

Here's what I waited for:

When to Migrate

My rule of thumb: wait until the third patch release (x.0.3+), confirm your other packages support the new version, and always upgrade Flutter/Dart first. Riverpod 2 still works fine — there's no rush unless you need specific Riverpod 3 features like the new Notifier lifecycle or improved code generation.

The Migration Audit — Assessing Your Codebase First

Before touching a single line of code, I audited each app. This audit determined the migration order and time estimates. Here's what I catalogued:

# Count providers by type across the project
grep -rn "StateNotifierProvider" lib/ | wc -l
grep -rn "StateProvider" lib/ | wc -l
grep -rn "FutureProvider" lib/ | wc -l
grep -rn "StreamProvider" lib/ | wc -l
grep -rn "ChangeNotifierProvider" lib/ | wc -l
grep -rn "\.family" lib/ | wc -l
grep -rn "\.autoDispose" lib/ | wc -l

# Find all ref.watch / ref.read usage
grep -rn "ref\.watch\|ref\.read\|ref\.listen" lib/ | wc -l

# Check for ProviderRef/WidgetRef type annotations
grep -rn "ProviderRef\|WidgetRef" lib/ | wc -l

The numbers told the story:

App StateNotifier Providers StateProviders FutureProviders Family Providers Total Providers
Fintech Dashboard 34 12 28 15 89
E-Commerce App A 22 18 14 8 62
E-Commerce App B 19 8 21 11 59
Logistics Tracker 45 6 38 22 111
Admin Panel 8 15 5 3 31

The StateNotifier count is the migration complexity multiplier. Each one needs manual conversion. The admin panel (31 providers, only 8 StateNotifiers) took a day. The logistics tracker (111 providers, 45 StateNotifiers) took a full week.

Migration Complexity Formula

Rough estimate: (StateNotifier count × 30 min) + (family provider count × 20 min) + (test file count × 15 min) = total migration hours. Add 30% buffer for unexpected issues. This formula was accurate within 15% across all 5 apps.

Real Time Estimates from 5 Production Apps

Everyone asks "how long will the migration take?" Here are real numbers, not estimates:

App Total Providers Lines of Dart Migration Time Testing Time Total
Admin Panel 31 ~12k 4 hours 2 hours 6 hours
E-Commerce B 59 ~28k 1.5 days 0.5 days 2 days
E-Commerce A 62 ~31k 2 days 1 day 3 days
Fintech Dashboard 89 ~52k 3 days 1.5 days 4.5 days
Logistics Tracker 111 ~68k 4 days 3 days 7 days

The logistics tracker took disproportionately long because it had 22 family providers with complex parameter types and heavy ref.listen usage that needed reworking. The testing time was longer because it had integration tests that depended on provider initialization order.

Where the Time Actually Goes

The time breakdown surprised me. I expected the StateNotifierNotifier conversion to dominate. It didn't:

That 15% for silent breakages is the dangerous part. The compiler doesn't catch them. Your existing tests might not catch them. I'll cover those later in this article.

Automated Migration with riverpod_lint

The riverpod_lint package is your best friend during migration. But it doesn't do everything, and knowing its limits saves frustration.

Setting Up riverpod_lint for Migration

# pubspec.yaml
dev_dependencies:
  riverpod_lint: ^3.0.0
  custom_lint: ^0.7.0

# analysis_options.yaml
analyzer:
  plugins:
    - custom_lint

After adding the lint rules, run dart fix --apply to auto-fix what it can:

# Apply all available riverpod_lint fixes
dart fix --apply

# Or run custom_lint directly for more control
dart run custom_lint

# Check what changes it would make without applying
dart fix --dry-run

What riverpod_lint Fixes Automatically

What riverpod_lint Does NOT Fix

Migration Order Matters

Run riverpod_lint auto-fixes first, then do manual conversions. If you convert manually first and then run the linter, it might revert some of your changes or create conflicts. I learned this the hard way on E-Commerce App A.

StateNotifier → Notifier — It's Not Just a Rename

The docs make this look simple: replace extends StateNotifier<T> with extends Notifier<T>, swap the constructor for a build() method. Mechanically, yes. But the lifecycle differences caused real production bugs.

The Surface-Level Change

// Riverpod 2: StateNotifier
class CartNotifier extends StateNotifier<List<CartItem>> {
  CartNotifier(this._repository) : super([]);

  final CartRepository _repository;

  Future<void> loadCart() async {
    state = await _repository.getCart();
  }

  void addItem(CartItem item) {
    state = [...state, item];
  }
}

final cartProvider = StateNotifierProvider<CartNotifier, List<CartItem>>(
  (ref) => CartNotifier(ref.read(cartRepositoryProvider)),
);

// Riverpod 3: Notifier
class CartNotifier extends Notifier<List<CartItem>> {
  @override
  List<CartItem> build() {
    return [];
  }

  CartRepository get _repository => ref.read(cartRepositoryProvider);

  Future<void> loadCart() async {
    state = await _repository.getCart();
  }

  void addItem(CartItem item) {
    state = [...state, item];
  }
}

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

The Lifecycle Gotcha That Caused a Production Bug

In StateNotifier, the constructor runs once when the provider is first read. In Notifier, the build() method runs every time the provider is invalidated or refreshed. This difference broke my fintech app's WebSocket connection manager:

// This worked in Riverpod 2 — constructor ran once
class WebSocketNotifier extends StateNotifier<ConnectionState> {
  WebSocketNotifier(this._channel) : super(ConnectionState.connecting) {
    _channel.stream.listen(
      (_) => state = ConnectionState.connected,
      onError: (_) => state = ConnectionState.error,
      onDone: () => state = ConnectionState.disconnected,
    );
  }
  final WebSocketChannel _channel;
}

// This BREAKS in Riverpod 3 — build() runs on every invalidation
class WebSocketNotifier extends Notifier<ConnectionState> {
  @override
  ConnectionState build() {
    // BUG: This creates a NEW WebSocket connection every time
    // the provider is invalidated!
    final channel = ref.read(webSocketChannelProvider);
    channel.stream.listen(
      (_) => state = ConnectionState.connected,
      onError: (_) => state = ConnectionState.error,
      onDone: () => state = ConnectionState.disconnected,
    );
    return ConnectionState.connecting;
  }
}

The fix was to use ref.onDispose to clean up the listener and handle reconnection explicitly:

class WebSocketNotifier extends Notifier<ConnectionState> {
  StreamSubscription? _subscription;

  @override
  ConnectionState build() {
    final channel = ref.read(webSocketChannelProvider);
    _subscription = channel.stream.listen(
      (_) => state = ConnectionState.connected,
      onError: (_) => state = ConnectionState.error,
      onDone: () => state = ConnectionState.disconnected,
    );
    ref.onDispose(() => _subscription?.cancel());
    return ConnectionState.connecting;
  }
}

Watch for Constructor Side Effects

Any StateNotifier constructor that opens connections, starts timers, sets up listeners, or does I/O needs careful conversion. The build() method in Notifier re-runs on invalidation, so side effects must be paired with ref.onDispose cleanup. This pattern burned me on 3 out of 5 apps. See my complete migration guide for more lifecycle patterns.

The Ref Access Difference

In StateNotifier, you typically passed dependencies through the constructor. In Notifier, you access them through ref directly. This seems cleaner, but it changes when dependencies are resolved:

// StateNotifier: dependencies resolved at creation time
final cartProvider = StateNotifierProvider<CartNotifier, List<CartItem>>(
  (ref) => CartNotifier(
    ref.read(cartRepositoryProvider),  // Resolved once
    ref.read(authProvider),            // Resolved once
  ),
);

// Notifier: dependencies resolved on every build() call
class CartNotifier extends Notifier<List<CartItem>> {
  @override
  List<CartItem> build() {
    // These resolve fresh every time build() runs
    final auth = ref.watch(authProvider);  // Reactive!
    return [];
  }
}

This is actually a feature — the Notifier can react to dependency changes. But if your StateNotifier assumed stable dependencies, you'll get unexpected rebuilds. My e-commerce app's cart provider was rebuilt every time the auth token refreshed because ref.watch(authProvider) in build() made it reactive to auth changes.

AsyncNotifier Traps That Cost Me Hours

AsyncNotifier replaces the pattern of combining FutureProvider with StateNotifier. It's much cleaner — when it works. Here are the traps I fell into.

Trap 1: build() Returns a Future, But State Updates Don't

class ProductListNotifier extends AsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() async {
    return _fetchProducts();
  }

  // WRONG: This replaces the AsyncValue with a raw value
  Future<void> refresh() async {
    state = AsyncValue.data(await _fetchProducts());
    // Missing loading/error states!
  }

  // RIGHT: Use the AsyncValue lifecycle
  Future<void> refresh() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() => _fetchProducts());
  }

  Future<List<Product>> _fetchProducts() async {
    final repo = ref.read(productRepositoryProvider);
    return repo.getProducts();
  }
}

Trap 2: When build() Fires vs When It Doesn't

In FutureProvider, the future re-executed when the provider was invalidated. In AsyncNotifier, the build() method only re-executes on invalidation — not when you call state-mutating methods. I expected build() to re-run after calling my addProduct() method. It doesn't.

class ProductListNotifier extends AsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() async {
    // This only runs on first read and on ref.invalidate()
    return ref.read(productRepositoryProvider).getProducts();
  }

  Future<void> addProduct(Product product) async {
    await ref.read(productRepositoryProvider).create(product);
    // build() does NOT re-run here!

    // Option 1: Manually update state
    final current = state.valueOrNull ?? [];
    state = AsyncValue.data([...current, product]);

    // Option 2: Force rebuild via invalidation
    // ref.invalidateSelf();
  }
}

Trap 3: Error Recovery in AsyncNotifier

When build() throws, the provider enters an error state. But recovering from that error state was not intuitive. In my logistics app, a network timeout during initial load left the provider permanently in error state:

class ShipmentNotifier extends AsyncNotifier<List<Shipment>> {
  @override
  Future<List<Shipment>> build() async {
    return _fetchShipments(); // Throws on timeout
  }

  // This retry pattern works correctly
  Future<void> retry() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() => _fetchShipments());
  }

  // For periodic refresh without showing loading spinner
  Future<void> silentRefresh() async {
    // Keep previous data visible while refreshing
    final previous = state.valueOrNull;
    state = AsyncValue<List<Shipment>>.loading().copyWithPrevious(
      AsyncValue.data(previous ?? []),
    );
    state = await AsyncValue.guard(() => _fetchShipments());
  }
}

AsyncValue.guard Is Your Friend

Always use AsyncValue.guard() for state mutations in AsyncNotifier. It wraps the future in proper loading/error/data states automatically. Manually setting state = AsyncValue.data(...) skips loading states and can cause UI flickers. I standardized on this pattern across all 5 apps and it eliminated an entire category of bugs.

The Ref Unification That Broke My DI Layer

Riverpod 3 merges ProviderRef and WidgetRef into a single Ref type. In theory, simplification. In practice, it broke my clean architecture dependency injection layer.

I had a pattern where service classes accepted ProviderRef so they could read other providers without being tied to widgets:

// Riverpod 2: This pattern worked
class AnalyticsService {
  AnalyticsService(this._ref);
  final ProviderRef _ref;

  void trackPurchase(double amount) {
    final userId = _ref.read(authProvider).userId;
    // ... track
  }
}

// Riverpod 3: ProviderRef is gone
// Replace with Ref — but now widget-level refs could leak in
class AnalyticsService {
  AnalyticsService(this._ref);
  final Ref _ref;

  void trackPurchase(double amount) {
    final userId = _ref.read(authProvider).userId;
    // ... track
  }
}

The fix was straightforward, but the real issue was that my DI container (using riverpod for service locator) had dozens of these typed refs. A global find-and-replace from ProviderRef to Ref worked for all of them, but I had to be careful about import statements — Ref comes from riverpod, not flutter_riverpod.

# Bulk rename ProviderRef → Ref across the project
find lib/ -name "*.dart" -exec sed -i '' 's/ProviderRef/Ref/g' {} +

# Also fix WidgetRef type annotations used in non-widget classes
find lib/ -name "*.dart" -exec sed -i '' 's/WidgetRef ref/Ref ref/g' {} +

Family Providers — The Migration Nobody Talks About

Family providers with code generation got completely different syntax. Without code generation, the changes are smaller but still tricky. This was the second most time-consuming part of my migration.

Without Code Generation

// Riverpod 2
final productProvider = FutureProvider.family<Product, String>(
  (ref, productId) async {
    return ref.read(productRepositoryProvider).getById(productId);
  },
);

// Riverpod 3 — same syntax still works (not deprecated yet)
// But the recommended pattern is now:
class ProductNotifier extends FamilyAsyncNotifier<Product, String> {
  @override
  Future<Product> build(String productId) async {
    return ref.read(productRepositoryProvider).getById(productId);
  }

  Future<void> updateProduct(Product updated) async {
    await ref.read(productRepositoryProvider).update(updated);
    state = AsyncValue.data(updated);
  }
}

final productProvider = AsyncNotifierProvider.family<ProductNotifier, Product, String>(
  ProductNotifier.new,
);

With Code Generation

// Riverpod 3 with @riverpod — much cleaner
@riverpod
class Product extends _$Product {
  @override
  Future<ProductModel> build(String productId) async {
    return ref.read(productRepositoryProvider).getById(productId);
  }

  Future<void> update(ProductModel updated) async {
    await ref.read(productRepositoryProvider).update(updated);
    state = AsyncValue.data(updated);
  }
}

The Multi-Parameter Family Gotcha

My e-commerce apps had family providers with multiple parameters using records:

// Riverpod 2: Multiple params via record
final searchProvider = FutureProvider.family<List<Product>, ({String query, int page})>(
  (ref, params) async {
    return ref.read(searchRepositoryProvider).search(
      query: params.query,
      page: params.page,
    );
  },
);

// Usage
ref.watch(searchProvider((query: 'shoes', page: 1)));

// Riverpod 3 with code generation: Named parameters directly
@riverpod
Future<List<Product>> search(Ref ref, {required String query, int page = 1}) async {
  return ref.read(searchRepositoryProvider).search(query: query, page: page);
}

// Usage — much cleaner
ref.watch(searchProvider(query: 'shoes', page: 1));

The code-generated version is cleaner, but switching from record-based parameters to named parameters meant updating every call site. My logistics app had 22 family providers, and about a third used multi-parameter records. That's where the time went.

Family Migration Priority

If you're doing an incremental migration, leave family providers for last. They work fine in Riverpod 3 with the old syntax. Convert them to the new pattern only when you're ready to adopt code generation app-wide. Mixing old .family syntax with new @riverpod generated families in the same module causes confusion.

Code Generation — When @riverpod Is Worth It

The @riverpod code generation is Riverpod 3's flagship feature. After using it across all 5 apps, here's my honest assessment.

When It's Worth It

When It's Not Worth It

Build Time Impact

Code generation with build_runner adds overhead. Here's what I measured:

App Providers Cold Build (Before) Cold Build (After) Incremental Build
Admin Panel 31 18s 24s (+33%) 3s
E-Commerce A 62 32s 45s (+40%) 5s
Logistics 111 48s 72s (+50%) 8s

The cold build increase is noticeable. I mitigated it by running build_runner watch during development and only running full builds in CI. For the fintech app, I chose not to adopt code generation — the 50% cold build increase wasn't acceptable for their CI pipeline that runs 40+ builds per day.

Testing Through the Migration Without Losing Coverage

Testing is where most migration guides handwave. "Update your tests" they say. Here's what actually changed in my test suites.

ProviderContainer Changes

// Riverpod 2: Test setup
void main() {
  test('cart loads items', () async {
    final container = ProviderContainer(
      overrides: [
        cartRepositoryProvider.overrideWithValue(MockCartRepository()),
      ],
    );
    addTearDown(container.dispose);

    // Access the notifier
    final notifier = container.read(cartProvider.notifier);
    await notifier.loadCart();
    expect(container.read(cartProvider), isNotEmpty);
  });
}

// Riverpod 3: Same pattern mostly works, but Notifier initialization differs
void main() {
  test('cart loads items', () async {
    final container = ProviderContainer(
      overrides: [
        cartRepositoryProvider.overrideWithValue(MockCartRepository()),
      ],
    );
    addTearDown(container.dispose);

    // With Notifier, accessing .notifier triggers build()
    // Make sure mock dependencies are ready BEFORE this line
    final notifier = container.read(cartProvider.notifier);
    await notifier.loadCart();
    expect(container.read(cartProvider), isNotEmpty);
  });
}

The Async Testing Gotcha

With AsyncNotifier, the build() method is a Future. But when you read the provider in a test, you get the AsyncValue, not the resolved value. I had dozens of tests that assumed synchronous access:

// Riverpod 2 test — direct value access
test('products load', () async {
  final container = ProviderContainer(overrides: [...]);
  addTearDown(container.dispose);

  // FutureProvider returns the resolved value
  final products = await container.read(productsProvider.future);
  expect(products, hasLength(10));
});

// Riverpod 3 with AsyncNotifier — same pattern still works for .future
// But if you watch the provider directly, you get AsyncValue
test('products load', () async {
  final container = ProviderContainer(overrides: [...]);
  addTearDown(container.dispose);

  // .future still works
  final products = await container.read(productsProvider.future);
  expect(products, hasLength(10));

  // But direct read gives AsyncValue
  final asyncValue = container.read(productsProvider);
  expect(asyncValue, isA<AsyncData<List<Product>>>());
});

Test Migration Strategy

Run tests after every 5-10 provider conversions, not at the end. I kept a checklist and converted providers in dependency order — leaf providers (no dependencies on other providers) first, then work upward. This way, when a test fails, you know exactly which conversion caused it.

7 Patterns That Break Silently in Riverpod 3

These are the patterns that compile fine, pass basic tests, and then cause bugs in production. I documented each one after finding them the hard way.

1. Provider Disposal Timing

In Riverpod 2, autoDispose providers were disposed when the last listener was removed. In Riverpod 3, there's a one-frame delay. If your code assumed immediate disposal, you might read stale state:

// This race condition didn't exist in Riverpod 2
ref.invalidate(cartProvider);
// In Riverpod 3, the old notifier might still be alive for one frame
final cart = ref.read(cartProvider); // Might get stale data!

2. ref.listen Callback Signature

The ref.listen callback now receives AsyncValue? (nullable previous) instead of requiring both current and previous. If you had null checks on previous, they behave differently:

// Riverpod 2
ref.listen<AsyncValue<User>>(userProvider, (previous, next) {
  if (previous?.isLoading == true && next.hasValue) {
    // User loaded — this worked reliably
  }
});

// Riverpod 3 — previous is nullable on first call
ref.listen<AsyncValue<User>>(userProvider, (previous, next) {
  if (previous == null) return; // First call, previous is null
  if (previous.isLoading && next.hasValue) {
    // User loaded
  }
});

3. keepAlive on AutoDispose Providers

The keepAlive API changed. In Riverpod 2, you called ref.keepAlive() on the provider ref. In Riverpod 3, ref.keepAlive() returns a KeepAliveLink that you can later close. If you don't store the link, the compiler doesn't warn you, but the behavior differs.

4. Provider Override Ordering in Tests

When overriding providers in test containers, Riverpod 3 resolves overrides lazily. If provider A depends on provider B, and you override both, the override resolution order might differ from Riverpod 2. I found this in my fintech app's authentication tests.

5. StreamProvider Cancellation

In Riverpod 2, cancelling a StreamProvider immediately cancelled the underlying stream subscription. In Riverpod 3, there's a grace period for auto-disposed stream providers. If your stream has side effects on cancellation, the timing changes.

6. Notifier State Access Before build()

Accessing state in a Notifier before build() completes throws a StateError. In StateNotifier, the state was available immediately via the super constructor. If you have initialization logic that reads state, it breaks:

class FormNotifier extends Notifier<FormState> {
  @override
  FormState build() {
    _initializeValidation(); // If this reads state → crash
    return FormState.initial();
  }

  void _initializeValidation() {
    // WRONG: state isn't available until build() returns
    // if (state.isValid) { ... }

    // RIGHT: Do this after build() returns, in a post-frame callback
    // or defer initialization
  }
}

7. Multiple Family Parameters Equality

Family providers use == to determine if parameters changed. In Riverpod 2, this worked with records out of the box. In Riverpod 3 with code generation, the equality check on generated parameter objects can differ if you have custom == on your parameter types. My logistics app had a DateRange parameter with custom equality that stopped working after migration.

How to Catch Silent Breakages

Integration tests caught 5 of these 7 issues. Unit tests missed them because they don't exercise the full provider lifecycle. If your app doesn't have integration tests, write them for your critical user flows before migrating. Even 10-15 golden path tests can catch most lifecycle-related breakages. Check my testing strategy guide for integration test patterns.

The Incremental Strategy That Saved Me

Migrating all providers at once is tempting but dangerous. For the logistics app (111 providers), I used an incremental strategy that spread the risk across two weeks.

The Module-by-Module Approach

  1. Week 1, Day 1-2: Update packages, run riverpod_lint auto-fixes, fix compilation errors
  2. Week 1, Day 3: Convert the auth module (lowest risk — it's well-tested)
  3. Week 1, Day 4-5: Convert core data providers (repositories, API clients)
  4. Week 2, Day 1-2: Convert feature modules one at a time (shipments, tracking, reporting)
  5. Week 2, Day 3: Convert family providers and complex state notifiers
  6. Week 2, Day 4-5: Update tests, run full regression, deploy to staging

Each module conversion was a separate git commit with a descriptive message like migrate(auth): StateNotifier → Notifier, update tests. This made reverting a single module trivial if something went wrong.

The Compatibility Bridge Pattern

During incremental migration, old StateNotifier providers and new Notifier providers coexist. They can read each other without issues — Riverpod doesn't care what type of provider is on the other end of a ref.watch. This is what makes incremental migration possible.

// Old provider (not yet migrated)
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
  (ref) => AuthNotifier(ref.read(authRepositoryProvider)),
);

// New provider (already migrated) — can watch old provider fine
class CartNotifier extends Notifier<List<CartItem>> {
  @override
  List<CartItem> build() {
    // Watching a StateNotifierProvider from a Notifier works perfectly
    final auth = ref.watch(authProvider);
    if (auth.isLoggedOut) return [];
    return [];
  }
}

Branch Strategy

I used a long-running riverpod-3-migration branch, merged into develop daily. Never let the migration branch drift more than 2 days from the main branch — merge conflicts in provider files are miserable. For the smaller apps, I just migrated on the develop branch directly with feature flags.

Before/After Metrics from 5 Apps

Here's what actually changed in production after the migration stabilized. I measured these 2 weeks post-migration to account for any settling.

Performance Metrics

Metric Riverpod 2 (avg) Riverpod 3 (avg) Change
Provider read time (μs) 12.4 10.8 -13%
Widget rebuild count (per nav) 34 26 -24%
Memory usage (peak, MB) 186 171 -8%
App startup time (ms) 1,240 1,180 -5%
Crash-free sessions 99.2% 99.6% +0.4%

The widget rebuild reduction was the biggest real improvement. Riverpod 3's refined ref.watch is better at determining when a widget actually needs to rebuild. The performance gains were consistent across all 5 apps.

Code Quality Metrics

Metric Riverpod 2 Riverpod 3 Change
Lines of provider code 4,230 3,480 -18%
Provider-related lint warnings 47 3 -94%
Test coverage (state layer) 82% 89% +7%
Boilerplate lines eliminated 750

The 18% reduction in provider code came primarily from eliminating StateNotifier constructors and the boilerplate around StateNotifierProvider declarations. The test coverage increased because the new Notifier pattern is easier to test — no constructor dependency injection means simpler test setup.

Measuring Your Migration

Take baseline measurements before starting migration. I used Flutter DevTools for rebuild counts and timing, and Firebase Crashlytics for crash-free session rates. Without baselines, you can't prove the migration was worth the effort — and you'll want to prove it to your team or client.

Scripts That Automated 40% of the Work

I wrote shell scripts to handle the mechanical parts of migration. These saved hours across the 5 apps.

Bulk Type Rename Script

#!/bin/bash
# migrate-riverpod-types.sh
# Run from the Flutter project root

echo "=== Riverpod 3 Type Migration ==="

# Replace ProviderRef with Ref
echo "Replacing ProviderRef → Ref..."
find lib/ test/ -name "*.dart" -exec sed -i '' 's/ProviderRef/Ref/g' {} +

# Replace WidgetRef type annotations in non-widget files
echo "Replacing WidgetRef → Ref in service files..."
find lib/services/ lib/providers/ -name "*.dart" \
  -exec sed -i '' 's/WidgetRef/Ref/g' {} +

# Replace StateNotifierProvider declarations (simple cases)
echo "Flagging StateNotifierProvider for manual review..."
grep -rn "StateNotifierProvider" lib/ > migration-review.txt
echo "$(wc -l < migration-review.txt) StateNotifierProviders need manual conversion"

# Replace deprecated StateProvider patterns
echo "Replacing StateProvider.autoDispose → Provider.autoDispose (simple)..."
find lib/ -name "*.dart" -exec sed -i '' \
  's/StateProvider\.autoDispose/Provider.autoDispose/g' {} +

echo "=== Done. Review migration-review.txt for manual work ==="

Test File Migration Script

#!/bin/bash
# migrate-riverpod-tests.sh

echo "=== Migrating test files ==="

# Update ProviderContainer overrides syntax
find test/ -name "*_test.dart" -exec sed -i '' \
  's/\.overrideWithProvider/\.overrideWith/g' {} +

# Flag tests using StateNotifier-specific patterns
grep -rn "\.notifier\b" test/ | grep -v ".g.dart" > test-review.txt
echo "$(wc -l < test-review.txt) test lines access .notifier — review for lifecycle changes"

echo "=== Done ==="

Scripts Are Starting Points

These scripts handle the obvious mechanical changes. They won't catch every case — especially around family providers and complex generic types. Always review the changes with git diff before committing. I used the scripts as a first pass, then did a manual review of every changed file.

My Production Migration Checklist

I developed this checklist through the 5 migrations. Print it, check it off, don't skip steps.

Phase Step Details
Pre-Migration Audit provider counts by type Run the grep commands from the audit section
Pre-Migration Estimate time using the formula StateNotifiers × 30min + families × 20min + tests × 15min + 30% buffer
Pre-Migration Take baseline performance measurements DevTools rebuild counts, startup time, memory
Pre-Migration Write missing integration tests Cover critical paths: auth, checkout, data loading
Pre-Migration Create migration branch Branch from latest stable — merge daily
Packages Upgrade Flutter/Dart first Riverpod 3 requires Dart 3.0+ / Flutter 3.10+
Packages Update riverpod packages flutter_riverpod: ^3.0.0, riverpod_lint: ^3.0.0
Packages Run dart fix --apply Apply all automated lint fixes first
Migration Run bulk type rename scripts ProviderRef → Ref, WidgetRef → Ref where appropriate
Migration Convert leaf providers first Providers with no dependencies on other custom providers
Migration Convert StateNotifiers module by module One feature module at a time — commit after each
Migration Watch for constructor side effects Connections, timers, listeners need ref.onDispose
Migration Convert family providers last Most complex — save for when everything else works
Testing Run tests after every 5-10 conversions Don't batch — find bugs immediately
Testing Update test containers and overrides .overrideWithProvider.overrideWith
Testing Run integration tests Critical for catching the 7 silent breakage patterns
Verification Compare performance baselines Rebuild counts, startup time, memory usage
Verification Deploy to staging first Full QA pass before production
Verification Monitor crash-free rate for 48h Crashlytics/Sentry — watch for lifecycle-related crashes
Post-Migration Optional: adopt @riverpod code generation Separate PR — don't mix with migration

What I Would Do Differently

After five migrations, here's what I'd change:

  1. Start with the smallest app. I started with E-Commerce A (62 providers) thinking it was "medium." Should have started with the admin panel (31 providers) to build confidence with the patterns first
  2. Write integration tests before migrating. The fintech app had excellent unit tests but weak integration tests. Unit tests missed the lifecycle-related bugs. I wrote integration tests retroactively after finding production bugs — should have done it upfront
  3. Don't adopt code generation during migration. On E-Commerce B, I tried converting to @riverpod simultaneously with the Riverpod 3 upgrade. Two major changes at once made debugging impossible. Migrate first, adopt code gen later
  4. Invest more time in the audit phase. The logistics app took a full week because I underestimated the family provider complexity. A thorough 2-hour audit would have revealed that and adjusted the timeline
  5. Use signals for simple UI state. After the migration, I realized that some StateProvider instances (toggles, form state, tab selection) would have been simpler as Signals. Riverpod is still the right choice for complex state management, but not every piece of state needs a provider

The Bottom Line

Riverpod 3 is genuinely better than Riverpod 2. The performance improvements are real, the API is cleaner, and the code generation eliminates significant boilerplate. But the migration is not trivial — plan for it, audit your codebase, and migrate incrementally. The complete migration guide has the full API reference for every change. This article gave you the practical playbook. Use both.

Related Guides

Frequently Asked Questions

How long does it take to migrate a Flutter app from Riverpod 2 to 3?

For a medium-sized app (50-80 providers), expect 2-4 days of focused work. A large app with 150+ providers took me a full week. The bulk of time goes to StateNotifier-to-Notifier conversion and testing, not the actual dependency update. Using riverpod_lint auto-fixes can cut mechanical work by 40%.

Can I migrate Riverpod 2 to 3 incrementally?

Yes — incremental migration is the safest approach. Riverpod 3 is backwards-compatible for most provider types. Start by updating the package, then convert one feature module at a time. StateNotifier and Notifier can coexist in the same codebase during transition. I migrated one module per sprint across 3 of my production apps.

What are the biggest breaking changes in Riverpod 3?

The three biggest: (1) StateNotifier lifecycle changes — onDispose timing differs from Notifier's ref.onDispose, (2) ProviderRef and WidgetRef merged into a single Ref type, and (3) family provider syntax changed from positional to named parameters with code generation. These three caused 90% of my migration bugs across 5 apps.

Should I use Riverpod code generation with @riverpod?

For new code and greenfield projects, yes — code generation eliminates boilerplate and catches type errors at build time. For migration, I recommend converting existing providers to hand-written Notifier syntax first, then optionally adopting @riverpod in a second pass. Mixing generated and hand-written providers works fine.

Is Riverpod 3 faster than Riverpod 2?

In my benchmarks across 5 apps: provider read speed improved 8-12%, widget rebuild counts dropped 15-25% due to better selector granularity, and memory usage decreased roughly 10% with improved auto-dispose. The biggest real-world gain was fewer unnecessary rebuilds from the refined ref.watch behavior.

Does riverpod_lint help with migration?

riverpod_lint is essential. It catches deprecated patterns, suggests fixes, and provides auto-fix quick actions for common migrations like StateNotifier to Notifier. I estimate it handled about 40% of mechanical changes automatically. Run it after updating the package but before starting manual migration work.

What happens to StateNotifierProvider in Riverpod 3?

StateNotifierProvider still works in Riverpod 3 but is deprecated. It will be removed in a future version. The replacement is NotifierProvider with a Notifier class that uses a state property instead of extending StateNotifier. The key difference is lifecycle — Notifier is created lazily and tied to the provider's lifecycle, while StateNotifier was eagerly initialized.

Can I use Riverpod 3 with Flutter 2.x?

No. Riverpod 3 requires Dart 3.0+ and Flutter 3.10+ minimum. It relies on Dart 3 features like records, patterns, and sealed classes internally. If you're still on Flutter 2.x, you need to upgrade Flutter first. I recommend upgrading to the latest stable Flutter before starting the Riverpod migration.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.