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:
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!
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:
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:
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.
- This is a file that is generated with all the information of the classes that we define in
-
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.
- This file contains the definition of the
-
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 aStateNotifierProvider
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.
- 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
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:
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),
),
);
📖 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 🔥
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(),
),
);
}
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(),
);
}
}
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
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"
}
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,
];
}
// 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,
];
}
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
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();
}
}
}
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;
}
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
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.
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!');
}
}
}
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(),
);
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();
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
}
}
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!'),
);
}
}
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,
);
}
}
And this would be the result:
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.
Top comments (3)
Nice tutorial bro, but if I want to navigate to another screen after I pressed the button and after state change to JokesState.data? Can you explain it how bro?
In my apps, I need to press the button twice to run the navigate to another screen.
You guys are great! You and that Marcos Sevilla guy... I can't believe where you guys have taken me to with all of them Clean Architecture and Flutter Riverpod. God bless you bruv!
awesome write-up, learned a ton...but the gif/result at the end...seems a bit off?