Recently, I made a decision to change the state management library in my personal app. For a long time, I used Provider and Bloc packages for state management. GetX as well.
In medium-scale apps, I prefer using Provider with the MVVM pattern. But I realized that currently in the Flutter community, most developers use Riverpod. I thought that Provider was going to become a legacy skill. Riverpod provides more convenience than Provider, in terms of dependency injection and overall usability.
Riverpod doesn't rely heavily on context and the widget tree, and it allows easier testing.
My App's Architecture
Previously, my app's architecture followed Feature-First and Clean Architecture partially. The reason I designed it that way was that this was a private app and I didn't want to create a lot of boilerplate. But I did want to make this app maintainable and scalable. So even if I see this code again in the future, I can easily read it.
On the project root, there was a feature folder, and it included individual feature folders. Each feature had its own data, domain, and presentation folders. When a new feature was added, it would be placed in the feature folder, and it would have its own data, domain, and presentation folders as well.
When I changed Provider to Riverpod, my app's architecture didn't require many changes. My app didn't have many features, so I could easily refactor it.
What Changed
I just changed the spot where dependencies were injected, and changed ChangeNotifier to AsyncNotifier. The domain layer and data layer were not affected at all. I just changed some Provider functions to Riverpod methods.
When I used the Provider package, I followed the MVVM pattern, and after the migration, it didn't change. Because the architecture separated concerns properly, the migration only affected the presentation layer.
I haven't added an abstract layer or use cases yet, because it's enough to add them when needed.
Technical Decisions
During this process, I made some technical decisions. First of all, as far as I know, there are a few coding approaches when using Riverpod, such as StateNotifier, Notifier/AsyncNotifier, and codegen.
In my project, every API call was async, and the codegen approach would make this project over-engineered. So I thought AsyncNotifier was the best choice for my case.
Migration with Claude Code
I didn't want to migrate file by file, so I decided to use Claude Code for the migration. I focused on making sure Claude Code didn't touch the overall structure and approached the project carefully. I wanted to easily read the migrated code after the migration.
Here is the prompt I used:
Migrate the entire Flutter project from Provider to Riverpod (AsyncNotifier, no codegen).
## Scope
- Remove `provider` package, add `flutter_riverpod`
- Convert all ChangeNotifier classes to AsyncNotifier
- Declare Repositories as global Riverpod Providers
- Replace MultiProvider in main.dart with ProviderScope
- Convert all UI files using Provider API (Consumer, context.read, context.watch) to ConsumerWidget + ref
- Preserve all existing behavior: auto-login, country selection branching, book search/add/list
## Migration comments
Add brief inline comments at key migration points for learning reference.
Format: `// Migration: [what changed and why]`
Keep comments concise — one line per migration point, only at meaningful changes.
## Rules
- Do NOT modify Repository classes (data layer stays untouched)
- Do NOT modify Entity/model classes
- Do NOT add new features or refactor business logic
- Keep the same file/folder structure
Before & After
DI — main.dart
Before (Provider):
// main.dart
return MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => AuthProvider(AuthRepository()),
),
ChangeNotifierProvider(
create: (_) => BookProvider(BookRepository()),
),
],
child: MaterialApp(...),
);
After (Riverpod):
// main.dart
runApp(const ProviderScope(child: MyApp()));
// di.dart — Repositories declared as global providers
final authRepositoryProvider = Provider((ref) => AuthRepository());
final bookRepositoryProvider = Provider((ref) => BookRepository());
MultiProvider and constructor injection were replaced by ProviderScope at the root and global provider declarations. Dependencies are now resolved through ref instead of being passed through constructors.
State Management — book_provider.dart
Before (Provider):
class BookProvider with ChangeNotifier {
final BookRepository _repository;
BookProvider(this._repository);
List<Book> books = [];
bool isLoading = false;
String? error;
Future<void> fetchBooks() async {
isLoading = true;
error = null;
notifyListeners();
try {
books = await _repository.getBooks();
} catch (e) {
error = e.toString();
} finally {
isLoading = false;
notifyListeners();
}
}
}
After (Riverpod):
class BookNotifier extends AsyncNotifier<List<Book>> {
@override
Future<List<Book>> build() async {
return ref.read(bookRepositoryProvider).getBooks();
}
Future<bool> addBook(Book book) async {
try {
await ref.read(bookRepositoryProvider).addBook(book);
ref.invalidateSelf();
return true;
} catch (e) {
return false;
}
}
}
final bookNotifierProvider =
AsyncNotifierProvider<BookNotifier, List<Book>>(BookNotifier.new);
The isLoading, error, and notifyListeners() boilerplate disappeared entirely. AsyncValue handles loading, error, and data states automatically. The build() method replaces the manual fetchBooks() that was called in initState.
UI — books_page.dart
Before (Provider):
class BooksPage extends StatefulWidget {
// ...
@override
State<BooksPage> createState() => _BooksPageState();
}
class _BooksPageState extends State<BooksPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<BookProvider>().fetchBooks();
});
}
@override
Widget build(BuildContext context) {
return Consumer<BookProvider>(
builder: (context, provider, _) {
if (provider.isLoading) return const CircularProgressIndicator();
if (provider.error != null) return Text(provider.error!);
// ...
},
);
}
}
After (Riverpod):
class BooksPage extends ConsumerWidget {
// ...
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncBooks = ref.watch(bookNotifierProvider);
return asyncBooks.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text(e.toString()),
data: (books) => ListView.builder(...),
);
}
}
StatefulWidget with initState became a simple ConsumerWidget. The ref.watch automatically triggers the initial fetch and rebuilds the UI when data changes. The asyncBooks.when() pattern replaces the manual if/else chain for loading, error, and data states.
As a next step, I'm planning to add an abstract layer and use cases. My app has two ways to add a book — manual input and search via API — so abstracting this part makes sense. But before that, I want to refine the product direction and find a sharper niche first.
Top comments (0)