Using Riverpod and Freezed together in a Flutter application
by Chanan Braunstein, Owner
What is Riverpod?
Riverpod is a state management library that allows you to inject dependencies and encapsulate state in a flexible and testable way. It is created by the same author of Provider, which is another popular state management solution in Flutter. Riverpod has some advantages over Provider, such as:
- It does not depend on Flutter's context, which means you can access your state from anywhere in your app, without using widgets like Consumer or Provider.of.
- It supports multiple providers with the same type, which means you can have different instances of the same state without conflicts or naming issues.
You can learn more about Riverpod from its official documentation.
What is Freezed?
Freezed is a code generator that helps you create immutable classes with useful features, such as:
- Union types, which allow you to define multiple possible states for your class, and use pattern matching to handle them.
- Copy with, which allows you to create a new instance of your class with some fields modified.
- From and to json, which allow you to serialize and deserialize your class to and from json format.
Freezed works well with Riverpod, as it allows you to define your state classes with union types, and use them with StateNotifier, which is a state management tool that Riverpod provides.
You can learn more about Freezed from its official documentation.
How to use Riverpod and Freezed together?
To demonstrate how to use Riverpod and Freezed together, we will create a simple app that fetches data from a REST API and displays it in a list view. The API we will use is jsonplaceholder, which provides fake data for testing purposes. We will use the posts endpoint, which returns a list of posts with id, title, and body fields.
Step 1: Add dependencies
First, we need to add the dependencies for Riverpod, Freezed, Dio, and Build Runner in our pubspec.yaml
file. Dio is a HTTP client library that we will use to make API requests. Build Runner is a tool that we will use to generate code for Freezed.
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
hooks_riverpod: ^2.3.8
flutter_hooks: ^0.20.0
riverpod_annotation: ^2.1.2
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
dio: ^5.3.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
riverpod_generator: ^2.2.6
build_runner: ^2.4.6
custom_lint: ^0.5.2
riverpod_lint: ^2.0.1
freezed: ^2.4.2
json_serializable: ^6.7.1
After adding the dependencies, run flutter pub get
to install them.
You can also add all the dependencies at once via the command:
flutter pub add hooks_riverpod flutter_hooks riverpod_annotation freezed_annotation json_annotation dio dev:riverpod_generator dev:build_runner dev:custom_lint dev:riverpod_lint dev:freezed dev:json_serializable
Step 2: Create models
Next, we need to create the models for our data. We will use Freezed to generate immutable classes for our posts and API responses. To do that, we need to create two files: post.dart
and api_response.dart
in the lib/models
folder.
In the post.dart
file, we will define a class called Post with three fields: id, title, and body. We will also add the @freezed
annotation and the part statement to indicate that we want to use Freezed.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'post.freezed.dart';
part 'post.g.dart';
@freezed
class Post with _$Post {
const factory Post({
required int id,
required int userId,
required String title,
required String body,
}) = _Post;
factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
}
After creating the model file, we need to run the following command to generate the code for Freezed:
dart run build_runner watch --delete-conflicting-outputs
This will create the post.freezed.dart, post.g.dart files in the same folder. We don't need to worry about the content of these files, as they are generated by Freezed.
Step 3: Create providers
Now, we need to create the providers that will handle the API requests and return the models objects. We will use Dio to make the HTTP requests and handle the errors. We will create a file called dio.dart
in the lib/providers
folder. This will provide our posts provider with an instance of Dio:
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'dio.g.dart';
@riverpod
Dio dio(DioRef ref) => Dio();
In the post.dart
file, we will a Riverpod AsyncNotifierProvider a class called Posts
. The initial state will be an empty list which is return in the build method.
The method getPosts
is an async/Future method that will return the results for the API. The method starts by setting the state to AsyncValue.loading()
which the UI can use to show a loading indicator.
Then the method gets an instance of DIO, which it uses to make a GET request to the posts endpoint and parse the response data into a list of Post objects.
If the request is successful, it will set the state to the List of Posts. If the request fails, it will return a Failure state with the error message. This is done using the AsyncValue.guard()
function.
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/post.dart';
import '../providers/dio.dart';
part 'posts.g.dart';
@riverpod
class Posts extends _$Posts {
@override
FutureOr<List<Post>> build() {
return [];
}
Future<void> getPosts() async {
state = const AsyncValue.loading();
final dio = ref.read(dioProvider);
state = await AsyncValue.guard(() async {
final response = await dio.get(
'https://jsonplaceholder.typicode.com/posts',
options: Options(
contentType: 'application/json',
),
);
return List<Post>.from(response.data.map((item) => Post.fromJson(item)));
});
}
}
Step 4: Create UI
Finally, we need to create the UI that will display the posts from the API. We will update the main.dart
to accomplish this.First we need to initialize the Riverpod Provider:
void main() {
runApp(const ProviderScope(child: MyApp()));
}
Then we will use Riverpod's HookConsumerWidget to access our state and update our UI accordingly. We will also create an IconButton
to send the API request to load the data:
class MyHomePage extends HookConsumerWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(postsProvider).when(
data: (posts) => Scaffold(
appBar: AppBar(
title: const Text('Posts'),
),
body: Column(
children: [
IconButton(
onPressed: () => ref.read(postsProvider.notifier).getPosts(),
icon: const Icon(Icons.refresh),
),
_MyHomePage(posts: posts),
],
),
),
error: (err, stack) => throw Error(),
loading: () => Scaffold(
appBar: AppBar(
title: const Text('Posts'),
),
body: const CircularProgressIndicator(),
),
);
}
}
In the snippet above we handle the three states that the AsyncNotifierProvider
can be in: data, loading, and error. The states Error and loading are handled inline, but the data state is forwarded to _MyHomePage
widget which is shown here:
class _MyHomePage extends StatelessWidget {
const _MyHomePage({required this.posts});
final List<Post> posts;
@override
Widget build(BuildContext context) {
return Expanded(
child: ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(title: Text(post.title));
},
),
);
}
}
The full code of main.dart
is below:
import 'package:blog/models/post.dart';
import 'package:blog/providers/posts.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Posts',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends HookConsumerWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(postsProvider).when(
data: (posts) => Scaffold(
appBar: AppBar(
title: const Text('Posts'),
),
body: Column(
children: [
IconButton(
onPressed: () => ref.read(postsProvider.notifier).getPosts(),
icon: const Icon(Icons.refresh),
),
_MyHomePage(posts: posts),
],
),
),
error: (err, stack) => throw Error(),
loading: () => Scaffold(
appBar: AppBar(
title: const Text('Posts'),
),
body: const CircularProgressIndicator(),
),
);
}
}
class _MyHomePage extends StatelessWidget {
const _MyHomePage({required this.posts});
final List<Post> posts;
@override
Widget build(BuildContext context) {
return Expanded(
child: ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(title: Text(post.title));
},
),
);
}
}
Finally, if we did everything write the app should like so:
Conclusion
Riverpod and Freezed make developing interactive Flutter applications a breeze. Contact BK Software Development and let us develop the app of your dreams!