DEV Community

Cover image for A Riverpod 3 architecture for Flutter apps
Noa Tubić
Noa Tubić

Posted on Originally published at Medium

A Riverpod 3 architecture for Flutter apps

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: one execute() 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
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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
      );
}
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

GitHub logo 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

Pub Version License

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 Notifier base classes
  • State Management: BaseNotifier for handling initial/loading/error/data states
  • Pagination: Built-in PaginatedNotifier with 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
Enter fullscreen mode Exit fullscreen mode

Then run:

flutter pub get
Enter fullscreen mode Exit fullscreen mode

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)