Riverpod tells you how to hold state. It doesn't tell you how a feature should be put together. So every project, and often every developer, ends up with a different answer.
Almost every screen still does the same thing. Show a spinner, call the API, then show the data or an error. Without a shared structure, every notifier writes that again, slightly differently each time.
After a few projects I stopped reinventing it. riverpod_architecture is the structure I now start every Flutter app with.
What it does
Each feature follows the same path: a repository returns either a failure or the data, a notifier turns that into state, and the screen renders the state. The package gives you the pieces for each layer:
- Mappers that turn API responses into your own models, and forms into requests
-
BaseNotifier: oneexecute()call sets loading, then data or error -
PaginatedNotifier: infinite lists with next page, refresh, and errors that keep the items already loaded - Global loading, failure, and info messages for the whole app
- Sealed states, so the compiler checks you handled every case
It's built on Riverpod 3 and uses Either for results, so a failure is a value you return, not an exception you hope someone catches.
Try it
flutter pub add riverpod_architecture flutter_riverpod either_dart
Your repository returns either a failure or the data:
EitherFailureOr<User> getUser(int id) async {
try {
return Right(User.fromJson(await api.getUser(id)));
} catch (error, stackTrace) {
return Left(Failure(title: 'Could not load user', error: error, stackTrace: stackTrace));
}
}
The notifier is a few lines:
class UserNotifier extends AutoDisposeBaseNotifier<User> {
@override
void prepareForBuild() {}
Future<void> fetchUser(int id) => execute(
ref.read(userRepositoryProvider).getUser(id),
globalFailure: false, // show errors on this screen
);
}
And the screen switches over the state:
switch (ref.watch(userNotifierProvider)) {
BaseInitial() || BaseLoading() => const CircularProgressIndicator(),
BaseError(:final failure) => Text(failure.title),
BaseData(:final data) => UserCard(user: data),
}
Forget a case and it won't compile.
One call, many behaviors
execute() takes a few switches for the cases that come up again and again:
await execute(
repository.saveProfile(profile),
withLoadingState: false, // keep the form on screen
globalLoading: true, // dim the whole app instead
globalFailure: true, // show the error as a snackbar
);
Saving a form is the classic example. You don't want the form replaced by a spinner. You want an overlay, and an error message that leaves the user's input alone. That's one call instead of a page of state handling.
By default, failures go to a single BaseWidget near the top of your app, so you decide once how errors look. Pass globalFailure: false when a screen should show its own error, like the user screen above.
Pagination without the edge cases
Paginated lists are where hand-written notifiers usually break. You load page 2 twice, or an error wipes out the 40 items already on screen.
PaginatedNotifier handles that. You write one method that loads a page:
@override
PaginatedEitherFailureOr<User> getListOrFailure(int page, [void parameter]) =>
repository.getUsers(page: page);
The notifier tracks the page, ignores duplicate "load more" calls, and keeps the loaded items when a page fails. Your screen calls getNextPage() when the user nears the end, and refresh() for pull to refresh.
When you don't need it
For a small app with two or three screens, Riverpod's own AsyncNotifier and AsyncValue are enough. Adding base classes there is more structure than the app needs.
It pays off when you have dozens of screens and want them to load, fail, and paginate the same way. It also helps when a new teammate should be able to open any notifier and know where everything is.
The package started as a Riverpod 3 port of Q Agency's Q-Architecture, and the credit for the original design is theirs.
Try it yourself
Pick one screen that loads data and move its notifier to BaseNotifier. If it feels lighter, do the next one. The example app has a single user screen and a paginated list:
NoaTubic
/
riverpod_architecture
A Flutter state management architecture built on Riverpod 3.0, providing reusable notifier classes and utilities for clean, scalable applications. This package reduces boilerplate by providing standardized patterns for handling state, errors, pagination, and data mapping.
Riverpod Architecture
A Flutter state management architecture built on Riverpod 3.x, providing reusable notifier classes and utilities for clean, scalable applications. This package reduces boilerplate by providing standardized patterns for handling state, errors, pagination, and data mapping.
Features
-
Modern Riverpod 3.x: Fully migrated to latest Riverpod with
Notifierbase classes -
State Management:
BaseNotifierfor handling initial/loading/error/data states -
Pagination: Built-in
PaginatedNotifierwith infinite scroll support -
Error Handling: Functional error handling with
Either<Failure, T> - Global Providers: App-wide loading, failure, and info notifications
- Data Mapping: Mappers for API responses, forms, and requests
- Type Safety: Sealed classes with pattern matching
- Auto Dispose: Memory-efficient with auto-dispose variants
Installation
Add to your pubspec.yaml:
dependencies:
riverpod_architecture: ^1.0.6
flutter_riverpod: ^3.2.1
either_dart: ^1.0.0
Then run:
flutter pub get
Quick Start
1. Add BaseWidget through MaterialApp.builder
BaseWidget shows the global loading overlay and reports global failures…
https://pub.dev/packages/riverpod_architecture
Feedback and issues are welcome on GitHub.
Top comments (0)