DEV Community

loading...
Cover image for How to start using Riverpod, StateNotifier and Freezed in your Flutter applications.

How to start using Riverpod, StateNotifier and Freezed in your Flutter applications.

elianmortega profile image Elian Ortega ・13 min read

How to use the 3 tools together to manage the state of your application.

Haz clic aquí para la versión en español

More and more we hear of Riverpod as the new state management solution in Flutter. The big problem is that people trying to use it do not even know where to start.

In this article I am going to focus on how a project can be structured using Riverpod,StateNotifer and Freezed together to create the complete flow of state management.

What are we going to build?

We are going to build a jokes application, it's going to use the Jokes API to get a new programming joke and then show it in the screen. The result will look something like this:

https://raw.githubusercontent.com/NoScopeDevs/riverpod_jokes_basic/main/assets/readme/app_result.gif

Basics

I could make an explanation of each of these tools, but there is no need to reinvent the wheel. My friend Marcos Sevilla has some excellent articles explaining these tools.

  • Riverpod article
    • A tool that allows us to inject dependencies and encapsulate a state and then listen to this state.
  • StateNotifier article
    • It is a “re-implementation” of ValueNotifier, with the difference that it does not depend on Flutter.
  • Freezed article
    • It is a code generator for immutable classes.

Now that you know what each of these tools are and how they work, let's see how we can use them together as a state management solution.

⚠️ Before continuing it is important that you know the basics concepts of the tools so read the documentation!

Now let's get started!

https://media.giphy.com/media/Y3MbPtRn74uR3Ziq4P/source.gif

Folder Structure

In order to maintain a clean code base, the folder structure is key, so let's start with the idea of separating our application by features. If you have used flutter_bloc, you have seen that it is very common to create a bloc folder with its respective states, events and a views folder for the UI part of our feature, something like this:

https://raw.githubusercontent.com/NoScopeDevs/riverpod_jokes_basic/main/assets/readme/bloc_folder_structure.png

Carpeta generada con la extensión de VS Code de bloc v5.6.0

As you can see we have a feature called jokes in this you will find the logic and views of the jokes app. This structure may be familiar to many who have already used flutter_bloc.

But before looking at the code let's see the structure we want to achieve using Riverpod, StateNotifier and Freezed:

https://raw.githubusercontent.com/NoScopeDevs/riverpod_jokes_basic/main/assets/readme/riverpod_folder_structure.png

You can see that it is a very similar structure to the one proposed by flutter_bloc. But in this case, because of how riverpod works we have the following files:

  • jokes_state.dart
    • This file is where we define the possible states that the StateNotifier can emit. Commonly they are: initial, loading, data and error.
  • jokes_state.freezed.dart
    • This is a file that is generated with all the information of the classes that we define in jokes_state.dart, we shouldn't worry that much about its content, since it is generated code.
  • jokes_state_notifier.dart
    • This file contains the definition of the JokesNotifier, which is an implementation of StateNotifier. This will be the core of our state management, since in this class is where we define the methods that will be in charge of changing and emitting the new states when necessary.
  • jokes_provider.dart
    • This file is where we define the different types of Providers that we are going to use in this feature. In this case we will need 2, the first is a common Provider for the repository from where we will obtain a new joke and the second a StateNotifierProvider that is a special provider for StateNotifiers objects. Or in other words, we could say that this file is where the feature's dependency injection is done.

But with this last point, what happens if we have 2 different features that use the same provider?

Very simple, we can create a file where we are going to define global providers that may be used by several features at the same time. Something like this:

https://raw.githubusercontent.com/elian-ortega/Riverpod-StateNotifier-Freezed-Example/main/assets/shared_providers.png

And if you've already read the documentation, you know it, but just in case I remind you that combining providers with riverpod is very simple.

For example, let's imagine the scenario of a store where we define a Provider for the payment methods (PaymentMethods) and another provider for the Checkout, in this case the Checkout provider needs the PaymentMethods provider to be able to work and thanks to the ProviderReference parameter available when creating a new provider with riverpod, creating the relationship It is very simple:

///PaymentMethods Provider
final _paymentMethodsProvider = Provider<PaymentMethods>(
  (ProviderReference _) => PaymentMethods(),
);

///CheckOut Provider
final checkoutProvider = Provider<Checkout>(
  (ProviderReference ref) => Checkout(
    paymentMethodsProvider: ref.watch(_paymentMethodsProvider),
  ),
);
Enter fullscreen mode Exit fullscreen mode

📖 If you still do not understand this last part very well, I recommend you reading the articles of the tools that I left at the beginning, here.

Let's see the code 🔥

https://media.giphy.com/media/LmNwrBhejkK9EFP504/source.gif

Project setup

You may laugh at this step, but you would be amazed at how many times it is forgotten. To use riverpod you have to add a ProviderScope widget as the root of our application, this allows the communication of providers and is required to use riverpod.

///main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_jokes_basic/src/app.dart';

void main() {
  runApp(
    ProviderScope(
      child: RiverpodJokesApp(),
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

Personally, I like to separate the main.dart from the rest of the application and create an app.dart file that contains the root of the application and any extra configuration that is needed.

///app.dart

import 'package:flutter/material.dart';
import 'package:riverpod_jokes_basic/src/features/jokes/views/jokes_page.dart';

class RiverpodJokesApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: JokesPage(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

And just like that, we are ready to start using riverpod. Remember to include the dependencies in the pubspec.yaml file*,* as of the moment I write this article they are:

# pubspec.yaml

name: riverpod_jokes_basic
description: A new Flutter project.

publish_to: 'none'

version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  dio: ^4.0.0
  equatable: ^2.0.0
  flutter_riverpod: ^0.14.0+1
  freezed_annotation: ^0.14.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^1.12.2
  freezed: ^0.14.1+2
  json_serializable: ^4.1.0

flutter:

  uses-material-design: true
Enter fullscreen mode Exit fullscreen mode

Models

Before we can code the logic side of the application, we need to create the models that represent the objects from the API. In this case, we are going to be guided by the documentation provided by Jokes API, where they tell us that the response format looks like this:

/// JOKES API Response
{
    "error": false,
    "category": "Programming",
    "type": "twopart",
    "setup": "How many programmers does it take to screw in a light bulb?",
    "delivery": "None. It's a hardware problem.",
    "flags": {
        "nsfw": false,
        "religious": false,
        "political": false,
        "racist": false,
        "sexist": false,
        "explicit": false
    },
    "id": 1,
    "safe": true,
    "lang": "en"
}
Enter fullscreen mode Exit fullscreen mode

Just by looking at the response format, we can identify the 2 models that we are going to use. The first one is a model for the possible flags of the joke, we will call it FlagsModel and another model for the whole joke that we will call JokesModel.

These objects use equatable and json_serializable to generate the fromJson() and toJson() methods.

// flags_model.dart

import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'flags_model.g.dart';

@JsonSerializable()
class FlagsModel extends Equatable {
  FlagsModel({
    this.explicit,
    this.nsfw,
    this.political,
    this.racist,
    this.religious,
    this.sexist,
  });

  //Json Serializable
  factory FlagsModel.fromJson(Map<String, dynamic> json) =>
      _$FlagsModelFromJson(json);

  Map<String, dynamic> toJson() => _$FlagsModelToJson(this);

  final bool? explicit;
  final bool? nsfw;
  final bool? political;
  final bool? racist;
  final bool? religious;
  final bool? sexist;

  @override
  List<Object?> get props => [
        explicit,
        nsfw,
        political,
        racist,
        religious,
        sexist,
      ];
}
Enter fullscreen mode Exit fullscreen mode
// jokes_model.dart

import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

import './flags_model.dart';

part 'joke_model.g.dart';

@JsonSerializable()
class JokeModel extends Equatable {
  JokeModel({
    required this.safe,
    this.category,
    this.delivery,
    this.flags,
    this.id,
    this.lang,
    this.setup,
    this.type,
  });

  //Json Serializable
  factory JokeModel.fromJson(Map<String, dynamic> json) =>
      _$JokeModelFromJson(json);

  Map<String, dynamic> toJson() => _$JokeModelToJson(this);

  final String? category;
  final String? delivery;
  final FlagsModel? flags;
  final int? id;
  final String? lang;
  final bool safe;
  final String? setup;
  final String? type;

  @override
  List<Object?> get props => [
        category,
        delivery,
        flags,
        id,
        lang,
        safe,
        setup,
        type,
      ];
}
Enter fullscreen mode Exit fullscreen mode

Once these 2 classes are created, it is necessary to run the build_runner commands since json_serializable must generate the parsing methods of the classes.

We run the following commands:

# When the project depends on Flutter

flutter pub run build_runner build

# In project is pure dart

pub run build_runner build
Enter fullscreen mode Exit fullscreen mode

With this all syntax errors should disappear, and we can get to the next step.

Repository

On this occasion I am not going to focus much on this step or any clean architecture layer/component, since it is not the main goal. If you want to see more examples or documentation of clean architecture and its components you can check my networks where I have other articles, code repositories and videos talking about these concepts.

The reason for creating the repository with an interface, of course good coding practices, but also that you can see how the dependency injection with interfaces can be done when we create the providers.

The jokes_repository.dart contains the IJokesRepository interface and JokesRepository which is the implementation. These only have a Future<JokeModel> getJoke(); method that makes an API call to get a new joke. If the response is successful it returns the joke, otherwise an exception is thrown.

/// jokes_repository.dart

import 'package:dio/dio.dart';
import 'package:riverpod_jokes_basic/src/features/jokes/data/models/joke_model.dart';

abstract class IJokesRepository {
  Future<JokeModel> getJoke();
}

class JokesRepository implements IJokesRepository {
  final _dioClient = Dio();
  final url = 'https://v2.jokeapi.dev/joke/Programming?type=twopart';

  @override
  Future<JokeModel> getJoke() async {
    try {
      final result = await _dioClient.get(url);
      if (result.statusCode == 200) {
        return JokeModel.fromJson(result.data);
      } else {
        throw Exception();
      }
    } catch (_) {
      throw Exception();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

States using Freezed

In this case, our feature of generating random numbers is not very complex, the objective is to simulate the call to an API, and react to the different states in the UI. So, we are going to have 4 states: initial, loading (loading), data when we already have the new joke and error to give feedback to the user about what is happening.

/// jokes_state.dart

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_jokes_basic/src/features/jokes/data/models/joke_model.dart';

part 'jokes_state.freezed.dart';

///Extension Method for easy comparison
extension JokesGetters on JokesState {
  bool get isLoading => this is _JokesStateLoading;
}

@freezed
abstract class JokesState with _$JokesState {
  ///Initial
  const factory JokesState.initial() = _JokesStateInitial;

  ///Loading
  const factory JokesState.loading() = _JokesStateLoading;

  ///Data
  const factory JokesState.data({required JokeModel joke}) = _JokesStateData;

  ///Error
  const factory JokesState.error([String? error]) = _JokesStateError;
}
Enter fullscreen mode Exit fullscreen mode

Also, we added an extension method to simplify the comparison made to know if the current state is loading.

Once we have defined the structure of the states with the respective Freezed annotations, we must run a command to create the jokes_state.freezed.dart file with generated code.

In the terminal:

# In this case our project depend on flutter
# so we run

flutter pub run build_runner build

# If the project was pure dart we run

pub run build_runner build
Enter fullscreen mode Exit fullscreen mode

With this the new file will be generated resolving the syntax error from the states file.

⚠️ The jokes_state.freezed.dart code is generated so don't be stressed or intimidated if you don't understand it, it shouldn't be modified.

Implementing the StateNotifier

As I mentioned earlier, this is the core of our state management.

https://media.giphy.com/media/xUPGchIuMGVrjvVrDq/source.gif

In this class is where we will make the calls to the repository, and we will assign the states so that they are notified to the components in the UI.

/// jokes_state_notifier.dart

part of 'jokes_provider.dart';

class JokesNotifier extends StateNotifier<JokesState> {
  JokesNotifier({
    required IJokesRepository jokesRepository,
  })   : _jokesRepository = jokesRepository,
        super(const JokesState.initial());

  final IJokesRepository _jokesRepository;

  Future<void> getJoke() async {
    state = const JokesState.loading();

    try {
      final joke = await _jokesRepository.getJoke();
      state = JokesState.data(joke: joke);
    } catch (_) {
      state = JokesState.error('Error!');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

You can see that the first thing we do is in the super() method of the class we assign the first state to JokesState.initial(). Then, we only have a getJoke() method that makes a call to the repository within a try {} catch {} because as we saw previously, in some scenarios we are going to throw an exception. Based on this we will assign the new state to either state = JokesState.data(joke: joke); when the API call is successfull or state = JokesState.error('Error!'); when an exception is thrown and caught by the catch().

Exposing providers and injecting dependencies

Up to this point we have already created all the necessary components for the application logic to work. Now we must create 2 providers, first the Providers that inject the dependencies in this case of the JokesRepository with their respective interface and second the Provider that expose the StateNotifier in this case a StateNotifierProvider that exposes the JokesNotifier so that we can react to changes in states in the UI.

/// jokes_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_jokes_basic/src/features/jokes/data/repositories/jokes_repository.dart';
import 'jokes_state.dart';
export 'jokes_state.dart';

part 'jokes_state_notifier.dart';

///Dependency Injection

//* Logic / StateNotifier
final jokesNotifierProvider = StateNotifierProvider<JokesNotifier, JokesState>(
  (ref) => JokesNotifier(
    jokesRepository: ref.watch(_jokesRepositoryProvider),
  ),
);

//* Repository
final _jokesRepositoryProvider = Provider<IJokesRepository>(
  (ref) => JokesRepository(),
);
Enter fullscreen mode Exit fullscreen mode

You can see that as I showed you previously in the PaymentMethods/Checkout example, combining the providers is very simple, in this case we use the ProviderReference (ref) to inject the dependency that the JokesNotifier has of the repository.

Some might wonder why I declare _jokesRepositoryProvider as a private variable? This is simply for good practices, with this you avoid making the mistake of making calls directly to the repository from the UI.

With this we have concluded the implementation of all the necessary logic components for this application. Now we only have to implement the UI and react to the states.

Reacting to states in the UI

In this case, to simplify the example we only have one page in the UI. But before, I show you the code I want to emphasize 2 important points.

Obviously remember to read the documentation, but just to remind you:

How to call a method on the StateNotifierProvider

If you only want to call one method, in this case we need to call the getJoke() method of the StateNotifier:

/// To call a method 
context.read(providerVariable.notifier).methodToCall();

/// In our case to call the state notifier
context.read(jokesNotifierProvider.notifier).getJoke();
Enter fullscreen mode Exit fullscreen mode

How to listen to changes in states

If you want to listen to the state changes emitted by a StateNotifier there are different ways, but the simplest is to use a ConsumerWidget this gives us access to a property called watch of type ScopedReader with this we can listen to the different state changes of the wanted provider.

/// Using a ConsumerWidget
class NewWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final state = watch(randomNumberNotifierProvider);
    return ///Here you can do whatever you want with the state
  }
}
Enter fullscreen mode Exit fullscreen mode

In our case it is even simpler because when we use freezed to create states we have access to the when () method, this allows us to react to each of the states more easily avoiding the problem of many if - else conditions in the code .

For this implementation we could have something like this:

class _JokeConsumer extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final state = watch(jokesNotifierProvider);

    return state.when(
      initial: () => Text('Press the button to start'),
      loading: () => Center(child: CircularProgressIndicator()),
      data: (joke) => Text('${joke.setup}\n${joke.delivery}'),
      error: (error) => Text('Error Occured!'),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This ConsumerWidget return a different widget for each of the different states.

With this we are ready to create the page for our application:

/// jokes_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_jokes_basic/src/features/jokes/logic/jokes_provider.dart';

///JokesPage
class JokesPage extends StatelessWidget {
  ///JokesPage constructor
  const JokesPage({Key? key}) : super(key: key);

  ///JokesPage [routeName]
  static const routeName = 'JokesPage';

  ///Router for JokesPage
  static Route route() {
    return MaterialPageRoute<void>(builder: (_) => const JokesPage());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Riverpod Jokes'),
      ),
      body: Padding(
        padding: EdgeInsets.symmetric(horizontal: 20.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _JokeConsumer(),
            const SizedBox(height: 50),
            _ButtonConsumer(),
          ],
        ),
      ),
    );
  }
}

class _JokeConsumer extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final state = watch(jokesNotifierProvider);

    return state.when(
      initial: () => Text(
        'Press the button to start',
        textAlign: TextAlign.center,
      ),
      loading: () => Center(
        child: CircularProgressIndicator(),
      ),
      data: (joke) => Text('${joke.setup}\n${joke.delivery}'),
      error: (error) => Text('Error Occured!'),
    );
  }
}

class _ButtonConsumer extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final state = watch(jokesNotifierProvider);
    return ElevatedButton(
      child: Text('Press me to get a joke'),
      onPressed: !state.isLoading
          ? () {
              context.read(jokesNotifierProvider.notifier).getJoke();
            }
          : null,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

And this would be the result:

https://raw.githubusercontent.com/elian-ortega/Riverpod-StateNotifier-Freezed-Example/main/assets/functionality.gif

It's not the prettiest UI in the world, but that wasn't the goal, it was learning how to use riverpod, freezed, and statenotifier together as a state manager.

This is a fairly basic example if you want to see an example with a complete clean architecture setup I leave you these links:

Jokes application created with riverpod following clean architecture principles

App with COVID 19 API and minimal architecture

Qr generator application with testing flutter_bloc and riverpod implementations + clean architecture

For the future I plan to share an article on how to do the testing and other things.

As always...

Thanks to my friend Marcos Sevilla for this outro 😂, he also shares great content so here is his Twitter if you want to stay tuned of the content.

You can share this article to help another developer to continue improving their productivity when writing applications with Flutter.

There's a Spanish version of this article on Medium. Click here. You're welcome. 🇪🇸

Also, if you liked this content, you can find even more and keep in contact with me on my socials:

  • dev.to - where you're reading this article.
  • GitHub - where are my code repositories in case you like the examples.
  • GitHub NoScope - where you can find the code repositories used in the YouTube and Twitch channels.
  • LinkedIn - where I connect professionally.
  • Medium - where I publish my Spanish articles.
  • Twitter - where I express my short thoughts and share my content.
  • Twitch - where I do informal live shows from which I take clips with specific information.
  • YouTube - where I publish the clips that come out of my lives.

Discussion (1)

pic
Editor guide
Collapse
brightknight08 profile image
Tim Anthony Manuel

awesome write-up, learned a ton...but the gif/result at the end...seems a bit off?