Flutter Riverpod: Effortlessly Managing API Calls in Your App
How to fetch the data from the API by using Riverpod State Management?

Search for a command to run...
How to fetch the data from the API by using Riverpod State Management?

Lessons from Code, Content, and Consistency

Reliability basics: Outbox pattern + why it matters

Introducing Kafka/Redpanda + move to event-driven workflow

Connect Orders ↔ Inventory (first working service-to-service flow)

Inventory service + gRPC + proto contracts

Welcome to my blog! In this blog, we will be exploring how to make an API call using Riverpod.
If you have been following my Riverpod series, you know that we have covered a wide range of concepts related to this state management.
We have discussed what Riverpod is and how it works, so if you haven't had a chance to read those posts, make sure to check them out first.
In this blog, we will be putting all the things that we learned from the last two articles into the play and we will be creating two examples. So, Let's get started!
There are 2 API that we will use to create our example. In the first example, we will be using a Joke API to demonstrate how to make an API call using Riverpod.
The Joke API is a REST API that provides jokes in a consistent and well-formatted manner. Every time the API is called, it returns a random joke.
To start, we will design a simple user interface that consists of a Text widget to display the joke and a floating button that is used to retrieve a new joke from the API.
In the second example, we will be using Fake Store API. This API provides product lists. We will display those products and will add favorite/unfavourite functionality.
Here is the app that we will create at the end of the article.

Here is the project structure which we are going to follow during this example.

core: Contains core application files such as constants, DIO client, shared preferences, etc.
features: Contains a specific feature of an application. In our example, we will be having 4 main features: home, joke, products, and favorites.
routes: Contains application routing files.
main.dart: Entry point of the application.
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('title'.tr()),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
HomeButton(
title: 'jokes'.tr(),
onTap: () {
Navigator.of(context).pushNamed(Routes.joke);
}),
Gap(20.h),
HomeButton(
title: 'products'.tr(),
onTap: () {
Navigator.of(context).pushNamed(Routes.product);
}),
],
),
),
);
}
}

This page is having two main widgets:
Text: Used to display the joke which we will be getting from an API.
Floating Button: Used to refresh the joke.
class JokePage extends ConsumerWidget {
const JokePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
floatingActionButton: const RefreshJoke(),
appBar: AppBar(
title: const Text('Jokes'),
),
body: JokeView(),
);
}
}
class RefreshJoke extends ConsumerWidget {
const RefreshJoke({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return FloatingActionButton(
onPressed: (){
// Refresh new joke
},
child: const Icon(Icons.refresh),
);
}
}
class JokeView extends StatelessWidget {
const JokeView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(18.0),
child: Text(
'',
textAlign: TextAlign.center,
),
),
);
}
}
Output :

class ProductsPage extends ConsumerWidget {
const ProductsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).pushNamed(Routes.favourite);
},
child: const Icon(Icons.shopping_bag_outlined),
),
appBar: AppBar(
title: const Text('Products'),
),
body: ListView.separated(
itemCount: 1,
separatorBuilder: (BuildContext context, int index) {
return Gap(10.h);
},
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: const Text('Product Name'),
subtitle: const Text('Price'),
trailing: IconButton(
icon: const Icon(
Icons.favorite_outline,
),
onPressed: () {},
),
);
},
),
);
}
}
Output:

class FavouriteProductsPage extends ConsumerWidget {
const FavouriteProductsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Favourites'),
),
body: ListView.separated(
itemCount: 1,
separatorBuilder: (BuildContext context, int index) {
return Gap(10.h);
},
itemBuilder: (BuildContext context, int index) {
return ListTile(
key: const Key(''),
title: const Text('Product Name'),
subtitle: const Text('Price'),
trailing: IconButton(
icon: const Icon(
Icons.favorite,
),
onPressed: () {},
));
},
));
}
}

We will be using the freezed package to convert the raw JSON response into a Dart model.
We are only interested in joke and id from the response. So we will only be converting these two in our model.
For more information on how to use freezed, please refer to the documentation. We will not be discussing it in detail in this blog.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'joke_model.freezed.dart';
part 'joke_model.g.dart';
@freezed
class JokeModel with _$JokeModel {
const factory JokeModel({
String? joke,
int? id,
}) = _JokeModel;
factory JokeModel.fromJson(Map<String, dynamic> json) =>
_$JokeModelFromJson(json);
}
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_api/features/joke/data/models/joke_model.dart';
part 'joke_state.freezed.dart';
@freezed
class JokeState with _$JokeState {
factory JokeState({
@Default(JokeModel()) JokeModel joke,
@Default(true) bool isLoading,
}) = _JokeState;
}
flutter pub run build_runner build
First of all, we need two Classes :
API: This will get the raw JSON data from the API and
Repository: This will convert the JSON to our JokeModel.
To achieve this, Let's create a joke_api.dart file inside lib/features/joke/data/api which will get the raw JSON data for us.
class JokeApi {
final DioClient _dioClient;
JokeApi(this._dioClient);
Future<Map<String, dynamic>> fetchJokesApiRequest() async {
try {
final res = await _dioClient.get(Endpoints.jokes);
return res.data;
} catch (e) {
rethrow;
}
}
}
Now we want to convert this raw JSON data into our JokeModel.
To do that, create a file called joke_repository.dart in the lib/features/joke/data/repository folder and paste the code below into it.
class JokeRepository {
final JokeApi _jokeApi;
JokeRepository(this._jokeApi);
Future<JokeModel> fetchJoke() async {
try {
final res = await _jokeApi.fetchJokesApiRequest();
final jokeModel = JokeModel.fromJson(res);
return jokeModel;
} on DioError catch (e) {
final errorMessage = DioExceptions.fromDioError(e);
log(errorMessage.toString());
rethrow;
}
}
}
Now that we have these two files, we need to create providers for them.
Create joke_provider file, inside lib/features/joke/providers/. In this, we will be putting all of the providers
Let's create a simple provider for JokeApi and JokeRepository.
final jokeApiProvider = Provider<JokeApi>((ref) {
return JokeApi(ref.read(dioClientProvider));
});
final jokeRepositoryProvider = Provider<JokeRepository>((ref) {
return JokeRepository(ref.read(jokeApiProvider));
});
### joke\_state\_provider.dart
* The `JokeDataNotifier` class is meant to manage the state of a joke, and it accepts a `jokeRepository` object in its constructor, which it uses to fetch a joke when the `getJoke` the method is called.
* More on `StateNotifier` visit :
%[https://dhruvnakum.xyz/flutter-riverpod-stateprovider-statenotifier-statenotifierprovider-futureprovider-streamprovider#heading-statenotifier-andamp-statenotifierprovider]
class JokeDataNotifier extends StateNotifier<JokeState> {
JokeDataNotifier(this.jokeRepository) : super(JokeState()) {
getJoke();
}
JokeRepository jokeRepository;
Future<void> getJoke() async {
state = state.copyWith(isLoading: true);
await jokeRepository.fetchJoke().then((data) {
state = state.copyWith(joke: data, isLoading: false);
});
}
}
Let's understand the above code.
When getJoke is called, the JokeDataNotifier updates its internal state object by setting isLoading to true, and then it asynchronously fetches a joke using the jokeRepository object.
Once the joke is fetched, the JokeDataNotifier updates its internal state again by setting the joke property to the fetched joke, and setting isLoading to false.
Now that we've created our custom StateNotifier, we also need to create a Provider for it, so that we can use its state inside our widget.
Head over to joke_provider.dart and paste the below provider
final jokeDataProvider =
StateNotifierProvider.autoDispose<JokeDataNotifier, JokeState>((ref) {
return JokeDataNotifier(ref.read(jokeRepositoryProvider));
});
class JokePage extends ConsumerWidget {
const JokePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 1 -------------------------
final joke = ref.watch(jokeDataProvider).joke;
// 2 -------------------------
final isLoading = ref.watch(jokeDataProvider).isLoading;
return Scaffold(
floatingActionButton: const RefreshJoke(),
appBar: AppBar(
title: const Text('Jokes'),
),
// 3 -------------------------
body: isLoading
? const Center(
child: CircularProgressIndicator(),
)
: JokeView(jokeData: joke),
);
}
}
We are obtaining the joke state from the jokeDataProvider provider. We are using ref.watch in order to listen for a new changes and rebuilt the widget.
We obtained the isLoading property from the jokeDataProvider state using the ref.watch method. We will use this to conditionally render the UI according to the current state.
We are returning a CircularProgressIndicator if the isLoading property is true, or a JokeView widget if isLoading is false. The JokeView widget is passed the joke data as the jokeData property.
RefreshJoke Widget@override
Widget build(BuildContext context, WidgetRef ref) {
return FloatingActionButton(
onPressed: ref.read(jokeDataProvider.notifier).getJoke,
child: const Icon(Icons.refresh),
);
}
When the FloatingActionButton is pressed, the JokeDataNotifier's getJoke method will be called, which will cause the JokeDataNotifier to asynchronously fetch a joke and update its internal state.
Now let's look into JokeView widget
class JokeView extends StatelessWidget {
const JokeView({
Key? key,
required this.jokeData,
}) : super(key: key);
final JokeModel jokeData;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(18.0),
child: Text(
jokeData.joke ?? '',
textAlign: TextAlign.center,
),
),
);
}
}
As you can see we are simply using the jokeData 's joke property to display the joke on the screen.
And that's it !!! We completed our first example 🥳.

To begin, we will retrieve the products from the API and display them in a list.
Next, we will add the ability for users to favorite or unfavorite products by pressing the Heart Icon.
The product will be added or removed from the user's Favorite Page accordingly.
Users can access and view all their favorite products on their Favorite Page.
Let's begin!
From this API we only want id, title and price of the product, and we are also going to add a boolean isFavourite. Its default value will be false.
Let's generate the ProductModel class
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product_model.freezed.dart';
part 'product_model.g.dart';
@freezed
class ProductModel with _$ProductModel {
factory ProductModel({
int? id,
String? title,
double? price,
@Default(false) bool isFavourite,
}) = _ProductModel;
factory ProductModel.fromJson(Map<String, dynamic> json) =>
_$ProductModelFromJson(json);
}
Now run : flutter pub run build_runner build command
class ProductApi {
final DioClient _dioClient;
ProductApi(this._dioClient);
Future fetchProductsApiRequest() async {
try {
final res = await _dioClient.get(Endpoints.product);
return res.data;
} catch (e) {
rethrow;
}
}
}
class ProductRepository {
final ProductApi productApi;
ProductRepository(this.productApi);
Future<List<ProductModel>> fetchProductRequested() async {
try {
final res = await productApi.fetchProductsApiRequest();
final productModel =
(res as List).map((e) => ProductModel.fromJson(e)).toList();
return productModel;
} on DioError catch (e) {
final errorMessage = DioExceptions.fromDioError(e);
log(errorMessage.toString());
rethrow;
}
}
}
final productApiProvider = Provider<ProductApi>((ref) {
return ProductApi(ref.read(dioClientProvider));
});
final productRepositoryProvider = Provider<ProductRepository>((ref) {
return ProductRepository(ref.read(productApiProvider));
});
final isLoadingProductsProvider = StateProvider<bool>((ref) {
return true;
});
class ProductsNotifier extends StateNotifier<List<ProductModel>> {
// Fetching all products whenever anyone starts listning.
// Passing Ref, in order to access other providers inside this.
ProductsNotifier({required this.ref}) : super([]) {
fetchProducts(ref: ref);
}
final Ref ref;
Future fetchProducts({required Ref ref}) async {
await ref
.read(productRepositoryProvider)
.fetchProductRequested()
.then((value) {
// Setting current `state` to the fetched list of products.
state = value;
// Setting isLoading to `false`.
ref.read(isLoadingProductsProvider.notifier).state = false;
});
}
void toggleFavourite(int id, {required bool isFavourite}) {
// Getting the tapped Product and setting it's isFavourite to respective value.
final updatedProduct = state
.firstWhere((element) => element.id == id)
.copyWith(isFavourite: isFavourite);
// In order to trigger the consumer to rebuild.
// state must equal a new value of that object,
// updating variables of the state object itself will not work.
state = [
for (int i = 0; i < state.length; i++)
if (state[i].id == id) updatedProduct else state[i]
];
}
}
final productProvider =
StateNotifierProvider<ProductsNotifier, List<ProductModel>>((ref) {
return ProductsNotifier(ref: ref);
});
class ProductsPage extends ConsumerWidget {
const ProductsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(productProvider);
final isLoading = ref.watch(isLoadingProductsProvider)
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).pushNamed(Routes.favourite);
},
child: const Icon(Icons.shopping_bag_outlined),
),
appBar: AppBar(
title: const Text('Products'),
),
body: isLoading
? const Center(
child: CircularProgressIndicator(),
)
: ListView.separated(
itemCount: data.length,
separatorBuilder: (BuildContext context, int index) {
return Gap(10.h);
},
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text(data[index].title ?? ''),
subtitle: Text('\$${data[index].price.toString()}'),
trailing: data[index].isFavourite
? IconButton(
icon: const Icon(
Icons.favorite,
),
onPressed: () { ref.read(productProvider.notifier).toggleFavourite(
data[index].id ?? 0,
isFavourite: false);
},
)
: IconButton(
icon: const Icon(
Icons.favorite_outline,
),
onPressed: () { ref.read(productProvider.notifier).toggleFavourite(
data[index].id ?? 0,
isFavourite: true);
},
),
);
},
),
);
}
}
OUTPUT:

final favouritesProvider = StateProvider<List<ProductModel>>((ref) {
final res = ref
.watch(productProvider)
.where((element) => element.isFavourite == true)
.toList();
return res;
});
As you can see, In order to get our favorite products, we are simply watching over the productProvider and filter out based on the isFavourite variable.
It's that simple.
The UI will be also the same as the Product Listing page except there will be only UnFavourite functionality and no Favouriting functionality.
class FavouriteProductsPage extends ConsumerWidget {
const FavouriteProductsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(favouritesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Favourites'),
),
body: data.isNotEmpty
? ListView.separated(
itemCount: data.length,
separatorBuilder: (BuildContext context, int index) {
return Gap(10.h);
},
itemBuilder: (BuildContext context, int index) {
return ListTile(
key: Key(data[index].id.toString()),
title: Text(data[index].title ?? ''),
subtitle: Text('\$${data[index].price.toString()}'),
trailing: IconButton(
icon: const Icon(
Icons.favorite,
),
onPressed: () { ref.read(productProvider.notifier).toggleFavourite(
data[index].id ?? 0,
isFavourite: false);
},
));
},
)
: const Center(
child: Text('No Favourites Found'),
),
);
}
}
OUTPUT:

There is still much to learn about Riverpod, so be sure to explore the documentation and examples on GitHub. Riverpod has introduced many new features recently.
In future blogs, I will go deeper into these features.
I hope you found this article useful and informative. If you have any questions, please leave them in the comments section.
Thank you for reading and I look forward to our next article. Until then..
