The Use of Architecture with Riverpod State Management - Case Study

May 24, 2022

This article is a case study of using Riverpod in bigger applications. It starts by discussing the Riverpod solution and its shortcomings for larger applications. It advocates architecture as a way of addressing them. In the second part, we discuss the exact solutions we use in more detail.

While working with applications built with Riverpod, our team found it hard to maintain the code once the applications got bigger. Moreover, we were having problems explaining how the application works to new developers joining the team. We treated it as a sign that things needed to change. We wanted to figure out how to work with Riverpod in the proper way.

We are not claiming that the described solution is the best one. It’s not the only one either. But we are using it now, and it makes our apps a little bit more readable.

What’s Riverpod?

Let’s start by introducing Riverpod. Riverpod is a framework based on the Provider package. The most important idea behind Riverpod was to create a state management tool with all the Provider’s strengths and add some special powers.

Riverpod has only one global scope, which is usually added on the top of the widget tree.

ProviderScope(
    observers: [ProviderLogger()],
    child: MyApp(),
);

Such an approach gives us less boilerplate code. It also eliminates runtime errors (like ProviderNotFoundException), which are pretty frequent when using Provider, especially in bigger applications. In short: Riverpod is independent of the widget tree and simpler to use at first glance.

Providers in Riverpod

There is an easy way to communicate between providers in Riverpod because all of them are globally accessible. We don’t need to build new scopes for every provider. There is a simple method to get them from one to another.

final provider = Provider<SomeClass>((ref) { 
    final result = ref.watch(anotherProvider);
    return SomeClass(result);
});

There are four types of providers in Riverpod:

  • Provider - the most universal one, we can use it to return a class, a value, or a method
  • FutureProvider - returns async values
  • StateProvider - returns a value
  • StateNotifierProvider - for managing states.

Additionally, Riverpod, with its global scope, doesn’t need third-party packages for injecting dependencies.

The Trap of Less Code Is Always Better

When speaking about Riverpod, we think about many elements which are much simpler than in other solutions. We can use FutureProvider to get some elements from the database, and with the “when” method, simply display it in UI using just a few lines:

final listProvider = FutureProvider<List<String>>((ref) async { 
    final result = await ref.watch(serviceProvider).getList();
    return result;
});
     
Consumer(
    builder: (context, ref, child) {

        final list = ref.read(listProvider);
     
        return list.when(
            loading: () => const Center(
                child: CircularProgressIndicator.adaptive(),
            ),
            list: (list) => ListView(list: list),
            error: (error) => Center(
                child: ErrorInfo(error: error),
            ),
        );
    },
);

This approach allows us to get any method from any place in the application and use it wherever we want. Isn’t it magic? Sounds delicious!

Outgrowing Simple Solutions

But let’s be honest - we are all just humans. When something is convenient, we are likely to overuse it. What will happen when our application grows bigger and bigger? Our simple solutions will get very hard to understand (even for ourselves!).

Craziness of comples Riverpod solution
Creature vector created by macrovector - www.freepik.com

Simple solutions are perfect for simple applications or prototypes. Unfortunately, not every application can make use of them.

When it comes to serious and complex applications meant for AppStores with many clients and features, simple solutions probably won’t be good enough. This is true not only for development but also for maintenance and updates. Additionally, it’s crucial to create readable solutions that anyone working with the app can understand.

What’s Architecture, And Why We Need It?

This is the time to introduce architecture. Sounds serious, doesn’t it?

What’s architecture, and how to use it? It’s simple. Architecture means structure. We need to structure our code with folders and files to create readable and clean applications. We can find architecture in every language and framework. So why don't use it in Flutter too?

The structure of folders and elements is pretty similar for every app:

  • Data layer with entities, http requests
  • Domain layer with models, repositories, services
  • Presentation layer with logic and views.

As simple as that.

All layers are fairly independent. So when we make some changes in UI, data and domain won’t be affected so much. When we make changes in data - domain and presentation layers will be preserved. Sounds good?

Architecture serves as a backbone of the code. It makes it easier to understand. With clean code you won’t need many hours to start working. Every teammate can work with any element in the app without the need to explain what is what and why it’s working like this. When you have your own standards in the team, the architecture makes working with apps a piece of cake.

Riverpod with Architecture - Case Study

In our team we have a few projects created with Riverpod. We’ve been working on a solution to make those apps more readable for some time. The main idea was to build one method for using Riverpod that everyone on the team would understand.

I want to share this solution with you using an example of a simple app showing a list of memes, which is built using Riverpod State Management.

You can clone the project from: https://github.com/ewaradomska/riverpod-arch-demo.git

Plugins We Use (Riverpod, Freezed, Json serializable, Retrofit, Dartz)

In this article I won’t dive deep into plugins and their functions. I just want to give you a clue which ones we are using most often and which are used in this example.

Riverpod

Obviously, we use Riverpod. To be precise, we don’t use hooks_riverpod here, just flutter_riverpod. But if you like to use hooks, there is no problem. This solution works with them too.

Freezed and Json serializable

To build our models and states, we use freezed. For models, we use json_serialization to implement fromJson and toJson methods. Freezed is the code generator, so we need to use ‘flutter pub run build_runner build’ to create all dependencies.

Retrofit

For HTTP, we use Retrofit or simple Dio - the example here will use Retrofit. It’s a code generator, too, and it’s really simplifying http requests.

Dartz

To handle errors in the app, we use dartz. It gives us the Either class and fold() method. With those, we can easily manage server requests and convert them into states (show data view after fetch or error view if something is not right).

Feature’s Architecture - Folder’s Structure

Our folder’s structure in features is almost always mostly the same.

We have:

  • models
  • http
  • interface
  • services
  • controllers
  • pages
  • widgets
  • utils - when necessary to build some converters or helper methods.

Let’s look at every one of the folders (in the order in which they are usually built):

Models

Built with freezed, models are our data. We get them from the database (via retrofit) and then show them to the user in the UI. Models are immutable.

Http

This folder contains the dio client. In our example, this class has only one method - get all the memes from the database.

Interface

Here, we will create an abstract class that will be implemented by services (for test purposes and the real ones)

Services

They use the http client to communicate with the database. They implement all methods from the interface - which is crucial when building test service at the beginning. It’s really easy to replace services when using the interface. Both interface and services use dartz’s Either to handle errors.

Controllers

This is the place where StateNotifiers are used. First, we build a state using freezed unions and then implement StateNotifier with its methods to emit different states in the UI.

Pages

This is the place where the states are used to show different views

Widgets

Contains elements and views.

Getting Data from the Database

First, we have to get data from a database. We start by creating a few models. Because of the response structure, we need to create some containers in the models (it can be done in the data layer as an entity and then just converted to models in the domain layer later). When a models’ structure is ready, we can create a dio client with its method to get data from the database.

@freezed
class Meme with _$Meme {
 
  factory Meme({
    required String id,
    required String name,
    required String url}) = _Meme;
 
    factory Meme.fromJson(Map<String, dynamic> json) => _$MemeFromJson(json);
}
final httpClientProvider = Provider<HttpClient>((ref) =>
    HttpClient(ref.watch(dioProvider), baseUrl: ref.watch(baseUrlProvider)));
  
@RestApi()
abstract class HttpClient {
    factory HttpClient(Dio dio, {String baseUrl}) = _HttpClient;
  
    @GET("/get_memes")
    Future<MemesContainer> getMemes();
}

As you can see, the httpClient has its own provider, which creates an object with all its dependencies at the top of the file. I prefer to get providers at the top of the class because I can see which dependencies they need and what they are really doing without the need to look for them.

This httpProvider is used by the service. Services, as mentioned before, are implementing the interface.

abstract class MemeInterface {
    Future<Either<MemeException, MemesContainer>> getMemes();
}

Service uses the http client and calls its method in try/catch to enable error handling. Here, when an error appears - the service will give us an Error object instead of Memes.

final memeServiceProvider = Provider<MemeInterface>(
    (ref) => MemeService(ref.watch(httpClientProvider)));
 
class MemeService implements MemeInterface {
    final HttpClient client;
    MemeService(this.client);
 
    @override
    Future<Either<MemeException, MemesContainer>> getMemes() async {
        try {
            final result = await client.getMemes();
            if (result.success) {
                return Right(result);
            } else {
                return const Left(MemeException.serverError());
            }
        } catch (e) {
            return const Left(MemeException.unknown());
        }
    }
}

Now when data is fetched from the database, we can simply configure the state and state notifier.

Managing State with StateNotifierProvider

First, we need to implement a state to hold information about (surprise, surprise!)…. the state of our feature.

To do it, I used a union provided by freezed package. As you can see, there are four states implemented here.

The initial state is the first state. We have a loading state (emitted when elements are fetching), an error state (emitted when an error has occurred), and finally, “Memes” state emitted when all memes are fetched. Of course, how you name the states is up to you.

In the states we can declare parameters. As you can see below, it’s nice to use an extension to get parameters from the state.

@freezed
class MemeState with _$MemeState {
    const factory MemeState.initial() = _Initial;
    const factory MemeState.loading() = _Loading;
    const factory MemeState.memes(MemesContainer memesContainer) = _Memes;
    const factory MemeState.error(MemeException error) = _Error;
}
 
extension MemeStateX on MemeState {
    MemesContainer? get memesContainer =>
        maybeWhen(memes: (memes) => memes, orElse: () => null);
}

And how to get to know which state is emitted? StateNotifierProvider will tell you this!

StateNotifierProvider implements methods for emitting states. As in the example below.

Again, at the top of the class I created a provider (StateNotifierProvider), which will be used in the UI.

final memesStateNotifierProvider =
    StateNotifierProvider<MemeStateNotifier, MemeState>(
        (ref) => MemeStateNotifier(ref.watch(memeServiceProvider)));
 
class MemeStateNotifier extends StateNotifier<MemeState> {
    final MemeInterface service;
    MemeStateNotifier(this.service) : super(const MemeState.initial());
 
    Future<void> getMemes() async {
        state = const MemeState.loading();
        final result = await service.getMemes();
        result.fold((l) {
            state = MemeState.error(l);
        }, (r) {
            state = MemeState.memes(r);
        });
    }
}

What’s the most important thing here? All providers created in this feature are global. But only one of them is really used in more than one class (state notifier). The rest of them are used only to inject dependencies in one class (http in service, service in state notifier).

When you are creating a very big project with Riverpod’s providers - remember that less is more!

UI Implementation

In the final step of the process, we need to implement states to the UI. It is really simple (as simple as using FutureProvider). First, we need to get memes with a state notifier method:

context.read(memesStateNotifierProvider.notifier).getMemes();

and then use the Consumer widget to show the proper element depending on the state:

Consumer(
    builder: (context, ref, child) {
 
        final state = ref.watch(memesStateNotifierProvider);
                
        return state.when(
            initial: () => const Center(
                child: CircularProgressIndicator.adaptive(),
            ),
            loading: () => const Center(
                child: CircularProgressIndicator.adaptive(),
            ),
            memes: (memes) => MemeListViev(memesList: memes.data.memes),
            error: (error) => Center(
                child: ErrorInfo(error: error),
            ),
        );
    },
);

And this is it!

Let’s Sum Up!

Riverpod, like any other state management, has pros and cons. If you want to use it for prototyping or a very small app, you can use all providers. This allows you to code at an amazing speed. But when bigger applications are considered, it’s crucial to use proper architecture and structures to prevent many unpredictable bugs in the app.

Wish you a nice day. Keep calm and code on ;)


Edit, 10.05.2023

Because there were a few questions about adding more methods to the notifier to show if it really works, I added two methods (adding and deleting a meme) to show that we can stay with one provider even when we need more than one method.

One important question which is important to ask yourself when you use StateNotifier is - if the method I need to use is really connected with this particular notifier state?

In my simple example I added two methods - delete meme and add meme which will be exactly connected with the state we have in my notifier - it will change the list of memes.

First of all we should of course add methods to interface and service, but I won’t do this here as my API doesn’t really allow me to make any changes in the list, so we need to work on a really abstract example (and on the list which is in the state).

The methods can look like this, and I will paste them in the MemeStateNotifier body:

Future<void> deleteMeme(String id) async {
    final List<Meme> newList =
        List.from(state.memesContainer?.data.memes ?? []);
    if (newList.isEmpty) {
      return;
    }
    // in real example you'll need use API call from service to   really delete the item, I cannot do this here
    // final result = await service.delete(id);
    //  result.fold((l){
    //    error occured, meme not deleted - this is the place you can add a state to show info about failed deleting process
    //}, (r) {
    //    deleting complete, changing the state
    newList.removeWhere((element) => element.id == id);
    state = MemeState.memes(
        MemesContainer(data: MemesData(memes: newList), success: true));
    // }
  }

  Future<void> addMeme(Meme meme) async {
    final List<Meme> newList =
        List.from(state.memesContainer?.data.memes ?? []);
    // in real example you'll need use API call from service to really add the item, exactly like with deleting
    // final result = await service.add(meme);
    //  result.fold((l){
    //    error occured
    //}, (r) {
    //    adding complete, changing the state
    newList.add(meme);
    state = MemeState.memes(
        MemesContainer(data: MemesData(memes: newList), success: true));
    // }
  } 

To use methods in the UI side you will need to use exactly the same provider as before:

ref.read(memesStateNotifierProvider.notifier).deleteMeme(id);
ref.read(memesStateNotifierProvider.notifier).addMeme(meme);

You can see those two simple methods and their usage in the updated repository. https://github.com/ewaradomska/riverpod-arch-demo.git

What You have to say about this - don't hesitate to comment :)

comments powered by Disqus