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

Riverpod Architecture: Dataflow

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](https://pub.dev/packages/flutter_riverpod) to your dependencies. It is the only package we need for this tutorial, and our pubspec.yaml file should look like this afterward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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

Also, we need to wrap our app in a ProviderScope to make Riverpod work.

1
2
3
void main() {
  runApp(const ProviderScope(child: MyApp()));
}

Entry Repository

Flutter Architecture with Riverpod: 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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;
  }
}

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()
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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);
}

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:

1
2
3
4
5
6
7
import 'package:flutter_riverpod/flutter_riverpod.dart';

final entryRepositoryProvider = Provider((_) => EntryRepository());

class EntryRepository {
  ...
}

State & Controller

Flutter Architecture with Riverpod: 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.

1
2
3
4
5
6
7
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();
});

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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);
  }
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
...

final homePageControllerProvider = Provider((ref) {
final entryRepository = ref.watch(entryRepositoryProvider);
return HomePageController(ref: ref, entryRepository: entryRepository);
});

...

class HomePageController {
...
}

User Interface

Flutter Architecture with Riverpod: UI

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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'),
      ),
    );
  }
}

We utilize this ref to retrieve the elements provided by entriesProvider. Here we also see how beautifully this can be done with Riverpod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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));
              },
            ),
      ),
    );
  }
}

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.

1
2
3
4
5
6
7
onAdd(WidgetRef ref) {
  ref.read(homePageControllerProvider).addEntry(DateTime.now().toString());
}

onRemove(WidgetRef ref, String entry) {
  ref.read(homePageControllerProvider).removeEntry(entry);
}

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 and read. 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 the read 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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),
      ),
    );
  }
}

We are done! It’s time to run the app and view what we have built.

The final result of the tutorial.

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.

Built with Hugo
Theme based on Stack designed by Jimmy