Member-only story

The state management revolution you didn’t know you needed

Riverpod 2.0 vs 3.0: Why You Should Upgrade RIGHT NOW (and How to Do It Without Breaking Everything)

Discover what changed between Riverpod 2.0 and 3.0, why it matters for your Flutter app, and exactly how to migrate without the headaches

12 min readOct 24, 2025

Hey friend! 👋

So you’ve been building your Flutter app with Riverpod 2.0 and crushing it, right? Well, buckle up because Riverpod 3.0 just dropped, and trust me — this isn’t just another version bump. This is the upgrade that’ll make you wonder how you ever lived without it.

Let me walk you through what’s changed, what’s better, and most importantly — how to upgrade without wanting to flip your desk. I’ve been through the trenches with this migration, and I’m gonna share everything I learned.

Why Should You Even Care About Riverpod 3.0?

Look, I get it. Migrating isn’t fun. But Riverpod 3.0 isn’t just about fixing bugs — it’s a complete rethinking of how state management should work in Flutter. Here’s the TL;DR:

The Big Wins:

  • 🔄 Automatic retry when network fails (no more manual error handling hell!)
  • 💾 Built-in offline persistence (your users will love you)
  • 🎯 Mutations for side-effects (finally, proper loading states for buttons!)
  • 🧹 Cleaner, unified API (less confusion, more productivity)
  • ⏸️ Automatic pause/resume (better performance, zero effort)

The Major Changes: What’s Actually Different?

1. Say Goodbye to AutoDispose Interfaces (Finally!)

Remember all those AutoDisposeProviderAutoDisposeNotifierAutoDisposeFamilyNotifier classes? Yeah, they're gone. Simplified. Unified.

Riverpod 2.0 (the old way):

// SO. MANY. VARIANTS. 😫
final myProvider = Provider.autoDispose((ref) {
return MyObject();
});

final familyProvider = Provider.family.autoDispose<String, int>((ref, id) {
return 'User $id';
});

Riverpod 3.0 (the new hotness):

// Clean and simple ✨
@Riverpod(keepAlive: false) // That's it. That's the tweet.
String myProvider(MyProviderRef ref) {
return 'Hello World';
}

// Families are now just... constructors
@riverpod
class UserNotifier extends _$UserNotifier {
UserNotifier(this.userId);
final int userId;

@override
String build() {
return 'User $userId';
}
}

See that? One parameter. keepAlive: false. Done. No more mental gymnastics remembering which variant to use.

2. The Notifier API: Your New Best Friend

This is the biggest conceptual shift. Instead of having StateProviderStateNotifierProvider, and ChangeNotifierProvider, we now have one unified approach: the Notifier API.

What Replaces What:

  • StateProvider → Notifier
  • StateNotifierProvider → Notifier (yep, same one)
  • FutureProvider → AsyncNotifier
  • StreamProvider → StreamNotifier

Why is this better? Everything related to one piece of state lives in ONE place. No more hunting through files.

Real Example — Todo List:

Riverpod 2.0:

// Scattered logic 😢
final todoListProvider = StateNotifierProvider<TodoListNotifier, List<Todo>>((ref) {
return TodoListNotifier(ref);
});

class TodoListNotifier extends StateNotifier<List<Todo>> {
TodoListNotifier(this.ref) : super([]);
final Ref ref;

void addTodo(String description) {
state = [...state, Todo(description: description)];
}

void toggleTodo(int id) {
state = [
for (final todo in state)
if (todo.id == id)
todo.copyWith(completed: !todo.completed)
else
todo
];
}
}

Riverpod 3.0:

// All in one place! 🎯
@riverpod
class TodoList extends _$TodoList {
@override
List<Todo> build() {
// Load initial state (could be from API, local storage, etc.)
return [];
}

void addTodo(String description) {
state = [...state, Todo(description: description)];
}

void toggleTodo(int id) {
state = [
for (final todo in state)
if (todo.id == id)
todo.copyWith(completed: !todo.completed)
else
todo
];
}

Future<void> fetchFromServer() async {
state = await api.getTodos();
}
}

// Usage in UI is IDENTICAL:
ref.read(todoListProvider.notifier).addTodo('Buy milk');

3. Automatic Retry: Network Errors? No Problem!

This is HUGE. In 2.0, when a provider failed, it just… failed. You had to manually handle retries, show errors, all that jazz.

3.0? It automatically retries with exponential backoff. 🤯

@riverpod
class UserProfile extends _$UserProfile {
@override
Future<User> build(int userId) async {
// If this throws (network error, timeout, etc.)
// Riverpod will automatically retry!
// Starts at 200ms, doubles each time, up to 6.4 seconds
return await api.getUser(userId);
}
}

Want to customize the retry behavior?

// Global configuration
void main() {
runApp(
ProviderScope(
retry: (retryCount, error) {
// Retry only network errors, max 3 times
if (error is NetworkException && retryCount < 3) {
return Duration(seconds: retryCount);
}
return null; // Don't retry
},
child: MyApp(),
),
);
}

// Per-provider configuration
@Riverpod(retry: customRetry)
class ApiData extends _$ApiData {
static Duration? customRetry(int retryCount, Object error) {
// Your custom logic
if (error is ServerError) return null; // Don't retry server errors
return Duration(seconds: retryCount * 2);
}

@override
Future<String> build() async {
return await api.fetchData();
}
}

4. Offline Persistence: The Feature You’ve Been Waiting For

Remember spending hours implementing local caching? Those days are OVER.

// First, add the dependency
// pubspec.yaml
dependencies:
riverpod_sqflite: ^1.0.0 # or latest version

// Setup storage
@riverpod
Future<JsonSqFliteStorage> storage(Ref ref) async {
return JsonSqFliteStorage.open(
join(await getDatabasesPath(), 'riverpod.db'),
);
}

// Use it in your provider
@riverpod
@JsonPersist() // This magical annotation!
class TodoList extends _$TodoList {
@override
Future<List<Todo>> build() async {
// This line does ALL the work
persist(
ref.watch(storageProvider.future),
// Optionally configure cache duration
// options: const StorageOptions(
// cacheTime: StorageCacheTime.unsafe_forever
// ),
);

// Fetch from server
// While loading, persisted data will be shown automatically!
final todos = await api.getTodos();
return todos;
}

Future<void> addTodo(String description) async {
final newTodo = await api.createTodo(description);
// This change automatically persists to the database
state = AsyncData([...await future, newTodo]);
}
}

What happens:

  1. First launch: Data loads from server, gets cached
  2. Next launch: Cached data shows INSTANTLY, then refreshes from server
  3. Offline: Cached data is still available
  4. Every state change: Automatically persisted

Zero boilerplate. 🙌

5. Mutations: Handle Side-Effects Like a Pro

This is for those “user taps button → show loading → show success/error” flows.

The Problem in 2.0:

// Scattered state, hard to test, annoying to manage
class AddTodoButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isLoading = ref.watch(isLoadingProvider);
final error = ref.watch(errorProvider);

if (isLoading) return CircularProgressIndicator();
if (error != null) return Text('Error: $error');

return ElevatedButton(
onPressed: () async {
ref.read(isLoadingProvider.notifier).state = true;
try {
await ref.read(todoListProvider.notifier).addTodo('New task');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Todo added!')),
);
} catch (e) {
ref.read(errorProvider.notifier).state = e.toString();
} finally {
ref.read(isLoadingProvider.notifier).state = false;
}
},
child: Text('Add Todo'),
);
}
}

The Solution in 3.0:

// Define your mutation once
final addTodoMutation = Mutation<void>();

// Use it cleanly
class AddTodoButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final addTodoState = ref.watch(addTodoMutation);

return switch(addTodoState) {
MutationIdle() => ElevatedButton(
onPressed: () {
addTodoMutation.run(ref, (tsx) async {
// tsx.get keeps the provider alive during the mutation
await tsx.get(todoListProvider.notifier).addTodo('New task');
});
},
child: Text('Add Todo'),
),
MutationPending() => CircularProgressIndicator(),
MutationError(:final error) => Column(
children: [
Text('Error: $error'),
ElevatedButton(
onPressed: () => addTodoMutation.run(ref, (tsx) async {
await tsx.get(todoListProvider.notifier).addTodo('New task');
}),
child: Text('Retry'),
),
],
),
MutationSuccess() => Column(
children: [
Icon(Icons.check_circle, color: Colors.green),
Text('Todo added successfully!'),
],
),
};
}
}

Why mutations rock:

  • ✅ Loading states handled automatically
  • ✅ Success/error states built-in
  • ✅ Providers stay alive during the operation
  • ✅ Easy to test
  • ✅ Clean separation of concerns

6. Equality Checking: == Instead of identical

This is subtle but important.

In 2.0: Some providers used identical (memory location check) In 3.0: ALL providers use == (value comparison)

Why this matters:

class User {
final String name;
User(this.name);

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User && name == other.name;

@override
int get hashCode => name.hashCode;
}

@riverpod
User currentUser(CurrentUserRef ref) {
return User('Alice');
}

// Later, you rebuild with:
// return User('Alice');

// 2.0: Widget rebuilds (different memory address)
// 3.0: No rebuild! (same name, equals returns true)

Result: Fewer unnecessary rebuilds, better performance! 🚀

Platform-Specific Setup: Android & iOS

Okay, here’s the critical stuff for offline persistence on each platform.

Android Setup

  1. Update build.gradle (app level):
android {
// Make sure you have at least this:
compileSdkVersion 34 // or higher

defaultConfig {
minSdkVersion 21 // Minimum for SQLite support
targetSdkVersion 34
}
}

2. Add Permissions in AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- For internet access (API calls) -->
<uses-permission android:name="android.permission.INTERNET"/>

<!-- For offline storage -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />


<application
android:name=".MainApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:usesCleartextTraffic="true">
<!-- For local testing -->
<!-- Your activity -->
</application>
</manifest>

3. ProGuard Rules (if you use code obfuscation): Add to proguard-rules.pro:

# Keep Riverpod annotations
-keep @riverpod.annotation.Riverpod class * { *; }
-keep class **_$* { *; }

# Keep SQLite
-keep class org.sqlite.** { *; }
-keep class org.sqlite.database.** { *; }

iOS Setup

  1. Update Podfile:
platform :ios, '12.0'  # Minimum for SQLite support

target 'Runner' do
use_frameworks!
use_modular_headers!

flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))

# Add if you use offline persistence
pod 'FMDB', '~> 2.7'
end

post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)

# Fix for SQLite on iOS 14+
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
end
end
end

2. Info.plist Configurations:

<dict>
<!-- Allow HTTP for local testing -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

<!-- For background fetch if you need it -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
</dict>

3. File Protection for Sensitive Data:

// In AppDelegate.swift (if you're storing sensitive data)
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Enable file protection
let fileManager = FileManager.default
let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
let dbPath = (documentsPath as NSString).appendingPathComponent("riverpod.db")

try? fileManager.setAttributes(
[.protectionKey: FileProtectionType.complete],
ofItemAtPath: dbPath
)

return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

Web Setup (Bonus!)

Web doesn’t support SQLite directly, but you can use shared_preferences_web:

@riverpod
Future<Storage> storage(Ref ref) async {
// Use shared preferences for web
if (kIsWeb) {
return SharedPreferencesStorage();
}
// Use SQLite for mobile
return JsonSqFliteStorage.open(
join(await getDatabasesPath(), 'riverpod.db'),
);
}

Migration Checklist: Don’t Break Your App!

Here’s your step-by-step guide:

Phase 1: Preparation

  • Update to Flutter 3.16+ (required!)
  • Update riverpod to ^3.0.0
  • Update flutter_riverpod to ^3.0.0
  • Update riverpod_annotation to ^3.0.0
  • Update riverpod_generator to ^3.0.0
  • Run flutter pub get

Phase 2: Code Changes

  • Run dart run riverpod_generator build --delete-conflicting-outputs
  • Replace Provider.autoDispose with @Riverpod(keepAlive: false)
  • Replace FamilyNotifier with regular Notifier + constructor args
  • Update ProviderObserver to use ProviderObserverContext
  • Remove AutoDispose prefixes from all types
  • Move ref.listenSelf calls inside Notifier classes

Phase 3: Testing

  • Test all providers in isolation
  • Test offline scenarios
  • Test error retry behavior
  • Test on physical devices (both Android & iOS)
  • Monitor for unexpected rebuilds

Phase 4: Optional Enhancements

  • Add offline persistence to key providers
  • Convert form submissions to use Mutations
  • Set up custom retry logic where needed
  • Add ref.mounted checks in async callbacks

Common Migration Gotchas (And How to Fix Them)

Issue 1: “The getter ‘state’ isn’t defined for the class ‘Ref’”

Problem: You’re trying to use ref.state outside a Notifier.

Fix:

// ❌ Old way
final myProvider = FutureProvider((ref) async {
ref.state = AsyncLoading(); // This won't work in 3.0!
return await fetchData();
});

// ✅ New way
@riverpod
class MyProvider extends _$MyProvider {
@override
Future<String> build() async {
// state is available here
state = const AsyncLoading();
return await fetchData();
}
}

Issue 2: “listenSelf isn’t defined”

Fix: Move it inside your Notifier class:

@riverpod
class Counter extends _$Counter {
@override
int build() {
// Move listenSelf here
listenSelf((previous, next) {
print('Counter changed from $previous to $next');
});
return 0;
}
}

Issue 3: Providers disposing during async operations

Solution: Use Mutations!

// Instead of this:
onPressed: () async {
await ref.read(myProvider.notifier).doSomething(); // Might dispose!
}

// Do this:
final myMutation = Mutation<void>();

onPressed: () {
myMutation.run(ref, (tsx) async {
await tsx.get(myProvider.notifier).doSomething(); // Stays alive!
});
}

Performance Tips for 3.0

  1. Use keepAlive: false wisely
// Don't cache rarely-used data
@Riverpod(keepAlive: false)
class RarelyUsedData extends _$RarelyUsedData {
@override
Future<String> build() async => await api.getRareData();
}

// DO cache frequently accessed data
@Riverpod(keepAlive: true)
class UserSession extends _$UserSession {
@override
User build() => getCurrentUser();
}

2. Implement proper == for your models

class Todo {
final int id;
final String title;

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Todo && id == other.id && title == other.title;

@override
int get hashCode => Object.hash(id, title);
}

3. Use ref.mounted in callbacks

Future<void> submitForm() async {
final result = await api.submit();

// Check if still mounted before updating state
if (ref.mounted) {
state = AsyncData(result);
}
}

Real-World Example: Complete Todo App

Let’s put it all together:

// models/todo.dart
@freezed
class Todo with _$Todo {
const factory Todo({
required int id,
required String description,
@Default(false) bool completed,
}) = _Todo;

factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}

// providers/storage.dart
@riverpod
Future<JsonSqFliteStorage> storage(Ref ref) async {
return JsonSqFliteStorage.open(
join(await getDatabasesPath(), 'riverpod.db'),
);
}

// providers/todo_list.dart
@riverpod
@JsonPersist()
class TodoList extends _$TodoList {
@override
Future<List<Todo>> build() async {
// Enable offline persistence
persist(ref.watch(storageProvider.future));

// Listen to self for logging
listenSelf((previous, next) {
if (next.hasValue) {
print('Todos updated: ${next.value!.length} items');
}
});

// Fetch from server (shows cached data while loading)
return await api.getTodos();
}

Future<void> addTodo(String description) async {
// Optimistic update
final tempTodo = Todo(
id: DateTime.now().millisecondsSinceEpoch,
description: description,
);
state = AsyncData([...await future, tempTodo]);

try {
final newTodo = await api.createTodo(description);
// Replace temp with real todo
state = AsyncData([
...await future.then((list) =>
list.where((t) => t.id != tempTodo.id)
),
newTodo,
]);
} catch (e) {
// Revert on error
state = AsyncData(
await future.then((list) =>
list.where((t) => t.id != tempTodo.id).toList()
),
);
rethrow;
}
}

Future<void> toggleTodo(int id) async {
// Optimistic update
state = AsyncData([
for (final todo in await future)
if (todo.id == id)
todo.copyWith(completed: !todo.completed)
else
todo
]);

try {
await api.updateTodo(id, completed: !completed);
} catch (e) {
// Revert on error
state = AsyncData([
for (final todo in await future)
if (todo.id == id)
todo.copyWith(completed: !todo.completed)
else
todo
]);
rethrow;
}
}

Future<void> deleteTodo(int id) async {
final backup = await future;
state = AsyncData(
backup.where((t) => t.id != id).toList(),
);

try {
await api.deleteTodo(id);
} catch (e) {
state = AsyncData(backup);
rethrow;
}
}
}

// Mutations for UI feedback
final addTodoMutation = Mutation<void>();
final deleteTodoMutation = Mutation<void>();

// UI
class TodoListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final todosAsync = ref.watch(todoListProvider);

return Scaffold(
appBar: AppBar(title: Text('My Todos')),
body: todosAsync.when(
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $error'),
ElevatedButton(
onPressed: () => ref.invalidate(todoListProvider),
child: Text('Retry'),
),
],
),
),
data: (todos) => ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (_) {
ref.read(todoListProvider.notifier).toggleTodo(todo.id);
},
),
title: Text(
todo.description,
style: TextStyle(
decoration: todo.completed
? TextDecoration.lineThrough
: null,
),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {
deleteTodoMutation.run(ref, (tsx) async {
await tsx.get(todoListProvider.notifier)
.deleteTodo(todo.id);

if (ref.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Todo deleted')),
);
}
});
},
),
);
},
),
),
floatingActionButton: AddTodoButton(),
);
}
}

class AddTodoButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final addTodoState = ref.watch(addTodoMutation);

return FloatingActionButton(
onPressed: addTodoState is! MutationPending ? () {
showDialog(
context: context,
builder: (context) => AddTodoDialog(),
);
} : null,
child: addTodoState is MutationPending
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(color: Colors.white),
)
: Icon(Icons.add),
);
}
}

class AddTodoDialog extends ConsumerStatefulWidget {
@override
ConsumerState<AddTodoDialog> createState() => _AddTodoDialogState();
}

class _AddTodoDialogState extends ConsumerState<AddTodoDialog> {
final controller = TextEditingController();

@override
Widget build(BuildContext context) {
final addTodoState = ref.watch(addTodoMutation);

// Listen for success and close dialog
ref.listen(addTodoMutation, (previous, next) {
if (next is MutationSuccess) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Todo added!')),
);
} else if (next is MutationError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${next.error}'),
backgroundColor: Colors.red,
),
);
}
});

return AlertDialog(
title: Text('Add Todo'),
content: TextField(
controller: controller,
decoration: InputDecoration(hintText: 'What needs to be done?'),
enabled: addTodoState is! MutationPending,
),
actions: [
TextButton(
onPressed: addTodoState is MutationPending
? null
: () => Navigator.pop(context),
child: Text('Cancel'),
),
ElevatedButton(
onPressed: addTodoState is MutationPending ? null : () {
addTodoMutation.run(ref, (tsx) async {
await tsx.get(todoListProvider.notifier)
.addTodo(controller.text);
});
},
child: addTodoState is MutationPending
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text('Add'),
),
],
);
}
}

Testing in 3.0: Even Better Than Before

New testing utilities make life so much easier:

// Old way (still works, but verbose)
test('old style', () {
final container = ProviderContainer();
addTearDown(container.dispose);

expect(container.read(counterProvider), 0);
});

// New way - cleaner!
test('new style', () async {
await ProviderContainer.test((container) {
expect(container.read(counterProvider), 0);
// Container automatically disposed after test
});
});

// Mock just the build method
test('mock build only', () async {
await ProviderContainer.test(
overrides: [
todoListProvider.overrideWithBuild(() => [
Todo(id: 1, description: 'Test todo'),
]),
],
(container) {
final todos = container.read(todoListProvider);
expect(todos, hasLength(1));

// Other methods still work!
container.read(todoListProvider.notifier).addTodo('New todo');
expect(container.read(todoListProvider), hasLength(2));
},
);
});

Key Takeaways

Look, migrating from 2.0 to 3.0 is work, but it’s totally worth it. Here’s why:

✅ Less code — Unified APIs mean less mental overhead

✅ Better performance — Automatic optimizations everywhere

✅ Built-in features — Retry, persistence, mutations out of the box

✅ Future-proof — This is the foundation for Riverpod’s future

✅ Easier testing — New utilities make testing a breeze

What’s Next?

Start small:

  1. Update dependencies
  2. Migrate one provider at a time
  3. Add persistence to key data
  4. Convert button actions to mutations
  5. Enjoy your new superpowers! 💪

The Riverpod team is still iterating on mutations and offline persistence (they’re marked experimental), but they’re stable enough for production use. Just keep an eye on the changelog for any API changes.

Now go forth and upgrade! Your future self (and your users) will thank you. 🚀

Got questions? Hit me up in the comments — I love talking about this stuff!

  • Flutter state management 2025
  • Riverpod 3.0 migration guide
  • Offline-first Flutter apps
  • Flutter automatic retry logic
  • Riverpod mutation patterns
  • Flutter SQLite integration
  • Flutter local caching strategies
  • Riverpod performance optimization
  • Flutter form handling best practices
  • Cross-platform state persistence

#FlutterDev #Riverpod #Riverpod3 #FlutterStateManagement #FlutterTips #MobileAppDevelopment #FlutterMigration #iOSDevelopment #AndroidDevelopment #FlutterBestPractices #OfflineFirst #FlutterPerformance #StateManagement #CrossPlatform #FlutterArchitecture #CleanCode #FlutterCommunity #DartLang #MobileDevelopment #SoftwareEngineering

Comments