- Home ›
- Categories ›
- Flutter
Tutorial: Simple Riverpod App Architecture in Flutter
Riverpod is a fantastic package for state management in Flutter. Learn how to build an app architecture around Riverpod and use it for dependency-injection.
Introduction
The riverpod
package is a great Flutter package for state management. It comes with good documentation (that you can find here ⇗), but it can be a bit tricky to set up an app architecture using this package.
In this tutorial, we are going to write a very basic app, showing how a Flutter app can be structured when using Riverpod.
You can find the source code for this tutorial on GitHub: https://github.com/bettercoding-dev/riverpod-architecture ⇗.
Data Flow

Let’s have a look at data flow within our app.
First, our app will have a state. A state can be a set of data that defines what to show to the user. In our example, this will be a list of entries that will be displayed in the app.
The user interface (UI) is updated according to the state. Every time the state changes, our UI is recreated with the new data.
When there are user interactions with the UI – such as button clicks – these events are forwarded to the controller. The controller is then responsible for performing actions accordingly and update the state.
The repository is responsible for managing the data. In our app, it will hold a list of entries, provide a way to fetch them and allow the add and remove these entries.
Prerequisites
Now, that we have a basic understanding, how the components of our app should look like, it’s time to start a new project.
I always perform a few steps when creating a new project, like cleaning up my pubspec.yaml
and main.dart
file. Take a look at my article 5 Things I do when starting a new Flutter project for more information.
Once the project is created, add flutter_riverpod ⇗
to your dependencies. It is the only package we need for this tutorial, and our pubspec.yaml
file should look like this afterwards:
name: simple_riverpod_architecture
description: Simple Riverpod architecture
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ">=2.12.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^1.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^1.0.0
flutter:
uses-material-design: true
Code language: YAML (yaml)
Also, we need to wrap our app in a ProviderScope
to make Riverpod work.
void main() {
runApp(const ProviderScope(child: MyApp()));
}
Code language: JavaScript (javascript)
Entry Repository

First, we start by creating a repository. This repository will be absolutely minimalistic and is just thought to mimic a repository that we would use in a larger app, such as a repository reading from a database or accessing a REST API.
We create a new folder in our src
directory and name it repositories
. Inside this directory add a file called entry_repository.dart
.
Add following class:
class EntryRepository {
final List<String> _entries = [];
Future addEntry(String entry) async {
await Future.delayed(const Duration(milliseconds: 100));
_entries.add(entry);
}
Future removeEntry(String entry) async {
await Future.delayed(const Duration(milliseconds: 100));
_entries.remove(entry);
}
Future<List<String>> allEntries() async {
await Future.delayed(const Duration(milliseconds: 200));
return _entries;
}
}
Code language: Dart (dart)
You might be wondering, why use async
functions with Future.delayed()
here. This is to simulate how a repository would behave when – for example – loading data from an API. These requests would be async
as well, and it makes sense to have a look at how to handle asynchronous calls.
Riverpod for Dependency Injection (DI)
Now, that we have a repository, we will have a look at how to use Riverpod to provide the EntryRepository
to other parts of the app.
One cool thing about Riverpod is that we can use its providers also to inject dependencies into other components. Let’s have look how this works.
class A {}
class MockA extends A {}
class B {
A a = A()
}
Code language: JavaScript (javascript)
In this example, class B
directly creates an instance of class A
. This has the disadvantage that if we want to replace the way A
is created, it is necessary to make changes to class B
. One situation, where we probably want to replace A
, is when testing our application. It is a common pattern to use mock instances instead of real instances.
For more information about mocking, read: https://flutter.dev/docs/cookbook/testing/unit/mocking ⇗.
So, how can we solve this problem with Riverpod? We simply need to create a Provider
for each component that we want to inject into another component.
For the example above, we can use Riverpod this way:
// A
final aProvider = Provider((ref) {
return A(); // or return MockA();
});
class A {}
class MockA extends A {}
// B
final bProvider = Provider((ref) {
final a = ref.watch(aProvider);
return B(a);
});
class B {
final A a;
B(this.a);
}
Code language: PHP (php)
We created a Provider
called aProvider
that is responsible for providing an instance of A
. In tests, we could override this Provider
to return a mock version of A
instead.
We also created a Provider
bProvider
that constructs an instance of class B
. You can see, that we used ref.watch(aProvider)
to retrieve an instance of A
from aProvider
and then return a new instance of B
the result.
So, for our project, this means that we need to create a Provider
for our EntryRepository
. I like placing this Provider
on top of the provided class:
import 'package:flutter_riverpod/flutter_riverpod.dart';
final entryRepositoryProvider = Provider((_) => EntryRepository());
class EntryRepository {
...
}
Code language: Dart (dart)
State & Controller

We are going to create new directories pages/home
in the src
directory. In home
put a new file called home_page_controller.dart
. This file is going to hold the state and the controller of our home page that we will create later.
First, we create a new Provider
called entriesProvider
that holds the state of the entries we want to display.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:simple_riverpod_architecture/repositories/entry_repository.dart';
final entriesProvider = FutureProvider((ref) {
final entryRepository = ref.watch(entryRepositoryProvider);
return entryRepository.allEntries();
});
Code language: JavaScript (javascript)
This should look similar to what we did earlier. One difference is that we now need to use a FutureProvider
instead of a Provider
. This is necessary because entryRepository.allEntries()
returns a Future
instead of a synchronous value.
Next, we add a class called HomePageController
. This class will be responsible for handling UI events.
class HomePageController {
final ProviderRef ref;
final EntryRepository entryRepository;
HomePageController({required this.ref, required this.entryRepository});
addEntry(String entry) {
entryRepository.addEntry(entry);
ref.refresh(entriesProvider);
}
removeEntry(String entry) {
entryRepository.removeEntry(entry);
ref.refresh(entriesProvider);
}
}
Code language: Dart (dart)
You can see, that HomePageController
has two attributes: ref
and entryRepository
. We are going to inject them later when creating a Provider
for our controller.
The ProviderRef
gives us access to functions involving the state of our app. It is used in lines 9 and 14 to tell the entriesProvider
to refresh its state once we performed actions on our EntryRepository
.
Finally, to make the HomePageController
available in our UI, we also need to add a Provider
for this class.
...
final homePageControllerProvider = Provider((ref) {
final entryRepository = ref.watch(entryRepositoryProvider);
return HomePageController(ref: ref, entryRepository: entryRepository);
});
...
class HomePageController {
...
}
Code language: Dart (dart)
User Interface

As the last step, we will create a HomePage
widget to view and manipulate entries in a list. If you have created a home_page.dart
file already, move it to src/pages/home
. Otherwise, create the file in this directory.
Instead of a StatelessWidget
, we will use a ConsumerWidget
for our page. ConsumerWidget
is part of the Riverpod package and offers us an additional WidgetRef
parameter for the build()
method. This parameter we can use to access the Providers
we created earlier.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class HomePage extends ConsumerWidget {
const HomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Entries Management'),
),
);
}
}
Code language: Dart (dart)
We utilize this ref to retrieve the elements provided by entriesProvider
. Here we also see how beautifully this can be done with Riverpod:
class HomePage extends ConsumerWidget {
const HomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Entries Management'),
),
body: ref.watch(entriesProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, trace) => Center(child: Text(error.toString())),
data: (data) => ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
final item = data[index];
return ListTile(title: Text(item));
},
),
),
);
}
}
Code language: Dart (dart)
We use ref.watch()
to listen to changes of the state of our entriesProvider
. The watch
function returns an AsyncValue
that allows us to call the when
function to provide different widgets depending on the state (loading, error, data) of the Future
. This is a really convenient way to provide loading and error widgets.
Interactivity
Now, let’s add away to add and remove entries. For this, we add 2 methods to our HomePage
.
onAdd(WidgetRef ref) {
ref.read(homePageControllerProvider).addEntry(DateTime.now().toString());
}
onRemove(WidgetRef ref, String entry) {
ref.read(homePageControllerProvider).removeEntry(entry);
}
Code language: JavaScript (javascript)
We use the passed WidgetRef
to access the HomePageController
using the read
function. Now we can simply call the functions of the controller.
When onAdd
is called, we simply add a new entry containing the current data. When onRemove
is called, we pass the entry that should be deleted to the controller.
WidgetRef
comes with two functions to access providers:watch
andread
.watch
should be used when you want to listen for changes to the provided value. This makes sense when being used for building widgets. In callbacks like button clicks or similar, we do care about changes made to the provider. In this case, we use theread
function to access the provider.Have a look at Riverpod’s documentation for more information on reading providers ⇗.
Next, we will link this functions with the UI. For adding new entries to our list, we create a FloatingActionButton
. The delete action will be done when tapping an element in the list. Finally, the HomePage
should look like this:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:simple_riverpod_architecture/pages/home/home_page_controller.dart';
class HomePage extends ConsumerWidget {
const HomePage({Key? key}) : super(key: key);
onAdd(WidgetRef ref) {
ref.read(homePageControllerProvider).addEntry(DateTime.now().toString());
}
onRemove(WidgetRef ref, String entry) {
ref.read(homePageControllerProvider).removeEntry(entry);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Entries Management'),
),
body: ref.watch(entriesProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, trace) => Center(child: Text(error.toString())),
data: (data) => ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
final item = data[index];
return ListTile(
title: Text(item),
onTap: () => onRemove(ref, item),
);
},
),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => onAdd(ref),
),
);
}
}
Code language: JavaScript (javascript)
We are done! It’s time to run the app and view what we have built.

The source code of this tutorial can be found on GitHub: https://github.com/bettercoding-dev/riverpod-architecture ⇗.
Summary
So, shortly, wrap up what we have learned in this tutorial.
First, we had a look at how the data flow in our app is going to look like. We learned which parts we need in our app and created the according components.
Second, we saw that we can use Riverpod not only for state management, but also for dependency injection and why it makes sense so build our app this way.
Finally, we built our user interface that interacts with a controller and reacts to changes in the app state that we created used Riverpod.
I hope this tutorial helps you to understand how to structure your app using Riverpod. Let me know in the comments if you want to see more tutorials about Riverpod on this blog. Also, if you have any questions about this tutorial, comment below.
Comments
Thanks for shared this tutorial!
Please help me compare between riverpod, flutter_riverpod and hooks_riverpod?
Hi Stefan,
Thank you for sharing this simple app architecture with Riverpod. I like the way you create the repository and the controller.
My question: If the repository is talking to a stream-based collection like Firebase Firestore, how will the repository react to changes made on the backend? Does the repository need to extend StateNotifier?
Hello Stefan,
Thanks for the great tutorial. This architecture is so close to MVVM architecture. Isn’t it almost the same?
Thanks for sharing this knowledge.
I wonder if it is possible to apply this same architecture in a larger project?
If so, would it be to replicate what you have done here in each use case or business rule, right?
Have a great day !!!
Yes, you can definitely use this architecture in larger projects as well. I’m currently using this approach for my Wave Budget app.
Exactly, you will have many of these classes and stats, but they are easy to manage. I recommend using a good folder structure to keep everything tidy 🙂