Master Riverpod in Flutter - Part 1: Understanding Core Concepts [2025]

Ayaan Haaris
AUTHOR
Ayaan Haaris

Getting started with Riverpod can be intimidating. You might find yourself asking, "Why are there so many providers?" or "How do I structure my code with Riverpod - where do providers even go?" or "Should I use code generation or not?".

If you've ever faced these questions, you're not alone - I've been there too.

Unfortunately, the official Riverpod documentation doesn't always make it easy to find these answers.

The release of Riverpod 2 brought significant breaking changes from version 1, the introduction of riverpod_annotation - a new and recommended way of declaring providers, combined with numerous outdated tutorials online, all of this has only added to the confusion.

This article is the first part of a two-part series designed to demystify Riverpod by walking you through the development of a to-do app to showcase its full potential.

I chose a to-do app for a reason: it's simple enough for beginners to grasp, yet complex enough to demonstrate Riverpod's powerful capabilities.

Here's an overview of what we will cover in each part of the series:

  1. Riverpod Basics: We'll start with the fundamentals, exploring what Riverpod is and how it works under the hood. Understanding different types of providers and their respective use cases. I'll illustrate these concepts with real-world examples to solidify your understanding.
  2. Building a Todos App: This segment will guide you through the hands-on creation of a todos app using the Async/NotifierProvider. You'll see how the theoretical aspects of Riverpod come to life in a practical application.

By the end of this series, my hope is that for you to have a solid understanding of how Riverpod works and feel confident applying it in your Flutter projects.

I'll do you one better! Why is Riverpod?

If you're familiar with provider or flutter_bloc, you already understand the importance of proper state management in Flutter.

While these solutions have served the community well, Riverpod takes state management to the next level by addressing common pain points and introducing powerful new capabilities.

The Riverpod package was developed from the ground up to solve several frustrating issues that Flutter developers frequently encounter with Provider.

You might have experienced some of these headaches yourself:

  • The infamous ProviderNotFoundException when the provider isn't found in the widget tree
  • Complicated nested provider scenarios leading to boilerplate code
  • Context-dependent provider access making testing and reusability challenging

😓

Like other state management solutions, Riverpod offers the essential benefits we've come to expect like: separation of concerns, centralized state management, and reactive UI updates.

However, what makes Riverpod truly stand out is its unique feature set:

  • Compile-time safety: Catch errors before your app runs, not during runtime. No more "Provider not found" errors in production!
  • Provider independence: Access state anywhere without context dependency, making your code cleaner and more flexible
  • Auto-disposal: Smart memory management that automatically cleans up unused providers, preventing memory leaks
  • Family modifiers: Easily parameterize providers for dynamic data needs without complex workarounds
  • Built-in caching: Efficient data persistence with minimal boilerplate, perfect for optimizing app performance
  • Seamless async handling: First-class support for Future and Stream operations, making async state management a breeze

What's particularly interesting is how Riverpod achieves these features while maintaining a familiar API for developers coming from Provider or other state management solutions.

This makes the transition smoother while unlocking more powerful capabilities.

Understanding Riverpod Timeline

Providers are the building blocks of state management in Riverpod, each serving a specific purpose in your application's architecture.

While Riverpod currently offers eight types of providers, recent changes in version 2 have streamlined which ones you should actually use in your projects.

Let's first look at all the available providers from Riverpod and then focus on the recommended ones for new projects:

Released in version 1:

  1. Provider
  2. FutureProvider
  3. StreamProvider
  4. StateProvider
  5. StateNotifierProvider
  6. ChangeNotifierProvider

Released in version 2:

  1. NotifierProvider
  2. AsyncNotifierProvider

Important Changes and Deprecations

The following providers have been deprecated and replaced in version 2:

1. StateNotifierProvider Deprecation

StateNotifierProvider is now deprecated in favor of the new NotifierProvider and AsyncNotifierProvider released in version 2 of Riverpod.

These new providers offer improved type safety and better integration with Riverpod's latest features.

2. StateProvider Deprecation

According to the official Riverpod documentation (https://riverpod.dev/docs/migration/from_state_notifier):

StateProvider was exposed by Riverpod since its release, and it was made to save a few LoC for simplified versions of StateNotifierProvider. Since StateNotifierProvider is deprecated, StateProvider is to be avoided, too.

3.ChangeNotifierProvider: A Special Case

ChangeNotifierProvider serves a specific purpose: helping developers transition from the traditional provider package to Riverpod.

While it works, it's not considered a core provider type for new Riverpod applications.

IMPORTANT

These changes are particularly significant to understand because many older tutorials and examples still use StateNotifierProvider and StateProvider.

Based on the latest version of Riverpod (v2), these are the actively maintained and recommended providers:

  1. Provider
  2. FutureProvider
  3. StreamProvider
  4. NotifierProvider
  5. AsyncNotifierProvider

TIP

When starting a new Flutter project with Riverpod, stick to these five providers. They cover all common use cases and represent the future direction of Riverpod.

NOTE

If you're maintaining or inherit a project that uses deprecated providers, don't panic! They'll continue to work, but consider gradually migrating to the recommended providers during your regular maintenance cycles.

Quick Reference

  • Provider: For simple immutable values
  • FutureProvider: For async operations that return a single value
  • StreamProvider: For reactive async data streams
  • NotifierProvider: For mutable state management
  • AsyncNotifierProvider: For mutable state with async operations
  • StateProvider: Not recommended (use NotifierProvider instead)
  • StateNotifierProvider: Deprecated (use NotifierProvider or AsyncNotifierProvider instead)
  • ChangeNotifierProvider: Only for Provider package migration

IMPORTANT

Given the frequent changes in Riverpod's API, including breaking changes, new features, and deprecations, this guide focuses exclusively on the latest implementation patterns.

By covering only the current best practices, my aim is to provide a clear, unambiguous guide without the confusion of legacy approaches.

Each concept is explained with straightforward examples that reflect Riverpod's current recommended usage.

Now that we've covered the current state of Riverpod's providers, let's explore each provider in detail, complete with practical examples and best practices.

NOTE

If this is your first time learning about Riverpod, it can be overwhelming to understand all of these different concepts. But still, i would recommend you to stick with me and continue reading.

Don't worry if you don't get everything right now. Once we build the todo app project on the 2nd part of this series, it will make a lot more sense.

As mentioned before, this first article focuses purely on understanding Riverpod concepts and principles.

We won't cover implementation details like project setup, dependencies, or actual coding yet.

These practical aspects will be covered in Part 2, where we'll build a todo app using the concepts learned here.

This approach allows us to first build a solid theoretical foundation before diving into hands-on development.

ProviderScope: The Root of All Providers

Before diving into providers, there's one crucial setup step: wrapping your app with ProviderScope.

This widget initializes Riverpod and manages the state of all providers in your application.

void main() {
  runApp(
    // Enable Riverpod for the entire app
    ProviderScope(
      child: MyApp(),
    ),
  );
}

Key points to know about ProviderScope:

  • Must be placed at the root of your widget tree
  • Handles the creation, maintenance and disposal of providers
  • Manages state persistence across widget rebuilds
  • Enables provider overrides for testing

We will cover this in more detail in the next part of this series.

Understanding the providers

Lets look at each provider - what they do, how they work and where to use them.

1. Provider

Starting with the most fundamental building block in Riverpod: the Provider.

Think of Provider as the simplest, no-frills member of the Riverpod family - it does one thing and does it well: it creates a value and holds onto it.


String appTitle(Ref ref) {
  return 'My Awesome App';
}

Let's break down this provider implementation:

  1. @riverpod: This annotation marks the appTitle function as a provider so Riverpod can generate the necessary provider code.
  2. String: Specifies the return type of the provider.
  3. appTitle: The provider's name, which will be used to access it throughout the application.
  4. Ref ref: The Provider receives a Ref parameter, which can be used to interact with other providers. We'll learn more about it later in the article.

IMPORTANT

The state of a Provider cannot be changed after it's created - it is immutable. This means once you set a value, you cannot modify it directly.

Setting Up Code Generation

When using the @riverpod annotation, there are a few additional steps needed to make everything work:

  1. First, add this line at the top of the file where the provider is declared:
part 'your_file_name.g.dart';
  1. Then run the build_runner command to generate the necessary provider code:
dart run build_runner build

TIP

During development, you might want to use dart run build_runner watch instead. This will automatically regenerate the code whenever you save changes to your files.

When the code generation completes, Riverpod will create a provider that you can use throughout your app.

The naming convention is simple - it adds "Provider" to the end of your function name. So our appTitle function becomes appTitleProvider:

// Using the generated provider
final title = ref.watch(appTitleProvider);

NOTE

If you're getting errors about missing generated files, double-check that:

  1. You've added the part directive at the top of your file
  2. The file name in the part directive matches your actual file name
  3. You've run the build_runner command successfully

Provider is incredibly useful in several key scenarios:

Caching Computations

When a provider is called, riverpod will execute the provider function and cache the result. It'll keep that value in memory, so we don't have to recalculate it every time.


int calculateValue(Ref ref) {
    int value = 0;
    value = someExpensiveCalculation();
    // Now the value will be cached and will be returned
    // immediately if this provider is called again
    return value;
}

Dependency injection

Provider is an excellent choice for dependency injection, the great thing about it is that it doesn't require a context to access the value.

final todosRepository = ref.read(todoRepositoryProvider);

final dioClient = ref.read(dioClientProvider);

Testing and Override Support

One of Provider's strengths is its flexibility during testing. It offers a clean way to override values, making your tests more manageable and reliable.

2. FutureProvider

FutureProvider is the asynchronous sibling of Provider. While it shares the same characteristics as the regular Provider - including immutable state and value caching - it's specifically designed to handle asynchronous operations.


Future<Weather> weather(Ref ref) async {
  final weatherApi = ref.watch(weatherApiProvider);
  return await weatherApi.fetchWeather();
}

This code shows how to create a FutureProvider that returns weather data asynchronously.

Just like in Provider, once the async operation is complete, the value will be cached and returned immediately if this provider is called again.

Return type must be Future<Weather> since we're fetching data. Notice the use of the async and await keywords in the provider declaration.

FutureProvider is very versatile and can be used in several scenarios:

  • API Calls and Network Requests
  • Complex Async Computations
  • Database Operations

NOTE

Unlike regular providers that return direct values, FutureProvider returns an AsyncValue. This wrapper provides a clean way to handle loading, error, and data states. We'll cover how to read and handle these states in the Reading Asynchronous Providers section below.

IMPORTANT

FutureProvider is designed for simple use cases and doesn't provide direct methods to modify the computation after user interaction. For such cases, consider using AsyncNotifierProvider instead discussed below.

3. StreamProvider

StreamProvider is like FutureProvider, but instead of handling one-time future values it handles continuous data streams.

It's perfect for scenarios where you need to work with real-time, continuously updating data.


Stream<int> counter(Ref ref) async* {
  int count = 0;
  while (true) {
    yield count++;
    await Future.delayed(const Duration(seconds: 1));
  }
}

This example shows a simple StreamProvider that emits an incrementing number every second.

StreamProvider is an excellent choice for real-time data handling scenarios like:

  • Firebase Realtime Database or Firestore streams
  • WebSocket connections
  • Live data updates from APIs
  • Periodic background operations

Periodic Updates

When you need to refresh data or perform operations at regular intervals:


Stream<Weather> liveWeather(Ref ref) async* {
  while (true) {
    yield await weatherApi.fetchCurrentWeather();
    await Future.delayed(const Duration(minutes: 5));
  }
}

TIP

Use async* and yield keywords when declaring a StreamProvider. The async* marks the function as a stream generator, while yield is used to emit values into the stream.

WARNING

When implementing a StreamProvider, remember that it continues running until explicitly cancelled or disposed of.

Just like FutureProvider, StreamProvider returns an AsyncValue wrapper instead of the direct stream value.

4. NotifierProvider

NotifierProvider is Riverpod's recommended solution for managing mutable state that changes in response to user interactions.

You can think of NotifierProvider as an alternative to ChangeNotifier from the provider package or Cubit from the flutter_bloc package.


class Counter extends _$Counter {
  
  int build() => 0; // Initial state

  void increment() => state++;
  void decrement() => state--;
}

Lets break down what's happening here:

  • We use the @riverpod annotation to declare a NotifierProvider. Riverpod will generate the provider code under the class _$<ClassName> when build command is run.
  • We extend the generated class _$Counter and override the build method that returns the initial state of the provider.
    
    int build() => 0;
    // <State Type> build() => <Initial State>;
    
  • state is a variable provided by the NotifierProvider class that holds the current state of the provider

IMPORTANT

Don't forget to include <filename>.g.dart on top and run dart run build_runner build after creating or modifying NotifierProvider classes to generate the necessary code.

Mutable State Management

Unlike Provider, FutureProvider and StreamProvider, NotifierProvider allows you to modify its state, making it perfect for:

  • User interaction handling (form inputs, button clicks)
  • State updates based on business logic

Centralized state modification

NotifierProvider helps you keep all related state modifications in one place:


class TodoList extends _$TodoList {
  
  List<Todo> build() => [];

  void addTodo(Todo todo) {
    state = [...state, todo];
  }

  void removeTodo(String id) {
    state = state.where((todo) => todo.id != id).toList();
  }
}

Modifying the state

The state property is only accessible inside the NotifierProvider class.

To modify the state outside of the NotifierProvider, you must use methods defined within the notifier class:

// Inside the NotifierProvider class

class TodoList extends _$TodoList {
  
  List<Todo> build() => [];

  void addTodo(Todo todo) {
    state = [...state, todo];
  }

  void removeTodo(String id) {
    state = state.where((todo) => todo.id != id).toList();
  }
}

// Using the notifier methods
ref.read(todoListProvider.notifier).addTodo(newTodo);
ref.read(todoListProvider.notifier).removeTodo(todoId);

IMPORTANT

Never try to modify the state directly from outside the NotifierProvider. Always create and use methods within the NotifierProvider class to handle state changes.

This ensures that state changes are handled correctly and consistently within the provider.

Understanding .notifier Access

When working with NotifierProvider or AsyncNotifierProvider, you'll often need to access the methods you've defined. This is done using the .notifier property followed by the method name:

// ❌ This won't work - can't access methods directly
ref.read(todoListProvider).addTodo(newTodo);

// ✅ Correct way - use .notifier to access methods
ref.read(todoListProvider.notifier).addTodo(newTodo);

Why do we need .notifier? Here's what's happening:

  • todoListProvider by itself gives you access to the current state List<Todo>
  • todoListProvider.notifier gives you access to the notifier class instance where your methods are defined

5. AsyncNotifierProvider

AsyncNotifierProvider is the asynchronous counterpart of NotifierProvider, designed specifically for managing state that requires asynchronous operations like API calls or database queries.


class UsersProvider extends _$UsersProvider {
  
  FutureOr<List<User>> build() async {
    // Initial async state
    return await _fetchUsers();
  }

  Future<void> addUser(User user) async {
    await _saveUser(user);
    state = AsyncData([...state, user]);
  }

  Future<void> removeUser(String id) async {
    await _deleteUser(id);
    state = AsyncData(state.value.where((user) => user.id != id).toList());
  }
}

Notice that the build method returns a FutureOr<List<User>> instead of just List<User>. This allows for async initialization.

IMPORTANT

AsyncNotifierProvider provides some useful helper methods to handle different states like AsyncLoading(), AsyncError(), and AsyncData(). We'll explore these in more detail in the Reading the Providers section below.

Procuring the Ref Object

Now that we understand what providers are, let's explore how to actually read their values in our widgets.

You can read/access the providers using the Ref object. Riverpod provides multiple ways to procure a Ref object.

ConsumerWidget

ConsumerWidget is Riverpod's equivalent to Flutter's StatelessWidget. It allows you to access providers while maintaining the simplicity of stateless widgets.

class UserProfile extends ConsumerWidget {
  const UserProfile({super.key});

  
  Widget build(BuildContext context, Ref ref) {
    // Using the ref object, we can access the user provider
    final user = ref.watch(userProvider);

    return Text(user.name);
  }
}

NOTE

Notice the additional ref parameter in the build method. Using this ref object, you can access the providers.

You might wonder why we need a special widget type instead of using regular Flutter widgets. Here's why:

  • Type Safety: ConsumerWidget ensures type-safe access to providers
  • Automatic Disposal: Properly handles provider subscriptions when the widget is disposed
  • Performance: Optimizes rebuilds to only the widgets that actually depend on the provider
  • Dependency Injection: Makes it easy to access providers anywhere in the widget tree

ConsumerStatefulWidget

For widgets that need to maintain state, Riverpod provides ConsumerStatefulWidget and ConsumerState.

This works similarly to Flutter's StatefulWidget but with the ref object available as a property throughout the entire ConsumerState class.

class Counter extends ConsumerStatefulWidget {
  const Counter({super.key});

  
  ConsumerState<Counter> createState() => _CounterState();
}

class _CounterState extends ConsumerState<Counter> {
  int localCount = 0;

  
  Widget build(BuildContext context) {
    // Access providers using ref
    final globalCount = ref.watch(counterProvider);

    return Column(
      children: [
        Text('Local count: $localCount'),
        Text('Global count: $globalCount'),
        ElevatedButton(
          onPressed: () => setState(() => localCount++),
          child: const Text('Increment Local'),
        ),
      ],
    );
  }
}

IMPORTANT

Remember that ref is available throughout the entire ConsumerState class, not just in the build method. So you can use it in life-cycle methods like initState, dispose etc to read providers using ref.read.

However, only call ref.watch inside the build method to avoid unexpected behaviors.

Consumer Builder

Sometimes, you only need to access providers in a small part of your widget tree. This is where Consumer comes in handy:

class MyWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('This widget doesn\'t need any provider'),
        Consumer(
          builder: (context, ref, child) {
            final count = ref.watch(counterProvider);
            return Text('Count: $count');
          },
        ),
      ],
    );
  }
}

When the Consumer widget is used, it will only rebuild the widgets that are inside the builder method.

In the example above, the Text widget that displays the count is the only widget that rebuilds when the counterProvider changes.

The child parameter in Consumer's builder can be used for widgets that don't need to or expensive to rebuild when the provider changes.

Consumer(
  child: const ExpensiveWidget(), // This is passed as the child parameter
  builder: (context, ref, child) {
    final count = ref.watch(counterProvider);
    return Column(
      children: [
        Text('Count: $count'),
        child!, // This widget won't rebuild when count changes
      ],
    );
  },
)

When to Use Consumer Builder?

  • When you only need provider access in a small part of your widget tree
  • To optimize rebuilds by limiting them to specific widgets
  • When working within existing StatelessWidget or StatefulWidget widgets

Reading the Providers

The Ref object offers various methods to read provider values. Each method serves a specific purpose, and knowing when to use each one is crucial for building efficient apps.

Reading Synchronous Providers

Synchronous providers like Provider and NotifierProvider return direct values.


class Counter extends _$Counter {
  
  int build() => 0;
}

final count = ref.watch(counterProvider);

These values can be accessed in several ways:

ref.watch

The ref.watch() method is your go-to approach for reading provider values reactively. When a provider's state changes, any widget watching it will automatically rebuild.

class CounterWidget extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // This widget will rebuild whenever the counterProvider value/state changes
    final count = ref.watch(counterProvider);

    return Text('Current count: $count');
  }
}

Use ref.watch() when you want your UI to automatically update in response to state changes.

Common use cases for ref.watch():

  • Displaying real-time data
  • Building UI elements that depend on state
  • Responding to user preferences changes

ref.read

ref.read() provides a non-reactive way to read a provider's value. It's perfect for one-time operations or event handlers.

class LoginButton extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    return ElevatedButton(
      onPressed: () {
        // One-time read inside an event handler
        final authService = ref.read(authServiceProvider);
        authService.login();
      },
      child: Text('Login'),
    );
  }
}

WARNING

Never use ref.read() directly in the build method like we did with the ref.watch(). This will prevent reactive updates and can lead to stale UI.

Common use cases for ref.read():

  • Event handlers (onPressed, onTap)
  • Non-build methods like initState, dispose etc.
  • One-time operations

ref.select

ref.select() allows you to listen to specific parts of a provider's state, preventing unnecessary rebuilds when other parts change.

class UserNameWidget extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // Only rebuilds when username changes
    final username = ref.watch(userProfileProvider.select((user) => user.username));

    return Text('Welcome, $username!');
  }
}

NOTE

Use ref.select() when dealing with complex state objects where you only need to react to specific property changes.

For example, with a user profile provider:


class UserProfile extends _$UserProfile {
  
  UserProfile build() => UserProfile();
}

// Only rebuild when email changes
final email = ref.watch(userProfileProvider.select((user) => user.email));

// Only rebuild when address changes
final address = ref.watch(userProfileProvider.select((user) => user.address));

NOTE

While ref.select() adds a bit more code, it can significantly improve performance in larger applications by reducing unnecessary rebuilds.

Common use cases for ref.select():

  • Large state objects where you only need specific fields
  • Lists where you only care about certain items
  • Complex objects with frequently changing properties

You can learn more about ref.select() here.

Reading Asynchronous Providers

Asynchronous providers (FutureProvider, StreamProvider, and AsyncNotifierProvider) return an AsyncValue type that requires special handling.


class UsersProvider extends _$UsersProvider {
  
  Future<List<User>> build() async => await api.getUsers();
}

// The users provider returns an AsyncValue<List<User>>
AsyncValue<List<User>> users = ref.watch(usersProvider);

Understanding AsyncValue

AsyncValue is a core concept in Riverpod that helps handle asynchronous operations elegantly. It's used by FutureProvider, StreamProvider, and AsyncNotifierProvider to represent the different states of async operations.

What is AsyncValue?

AsyncValue is a union type that can be in one of three states:

AsyncValue<T> {
  AsyncData<T>    // Successfully loaded data
  AsyncError      // Error occurred during loading
  AsyncLoading    // Currently loading
}

Lets see an example with a FutureProvider:


Future<List<User>> usersProvider(Ref ref) async {
  List<User> users = await api.getUsers();
  return users;
}

By default, the returned value from usersProvider will be of AsyncValue<List<User>> type.

AsyncValue<List<User>> users = ref.watch(usersProvider);

The most common way to handle AsyncValue is using the when method.

Reading with .when()

class UserListWidget extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // Watching the users provider. This will start the network request
    // if it wasn't already started. By using ref.watch, this widget
    // will rebuild whenever the usersProvider updates.
    final users = ref.watch(usersProvider);

    // Using the when method to handle different states
    return users.when(
      data: (users) => UserList(users),
      error: (error, stack) => ErrorWidget(error.toString()),
      loading: () => CircularProgressIndicator(),
    );
  }
}

After the ref.watch call, the usersProvider will start the network request if it wasn't already started.

  • First the state will be loading, so CircularProgressIndicator will be shown.
  • Then it will be data with the list of users, so UserList will be shown.
  • If the network request fails, the state will be error, so ErrorWidget will be shown.

NOTE

The when method is a powerful tool for handling different states of AsyncValue. It allows you to provide different widgets for each state and ensures that only the relevant widgets are rebuilt when the state changes.

Reading with .whenData()

When you only care about the data and want to provide a default value for loading/error states:

final users = ref.watch(usersProvider).whenData((users) => users);

There are other methods like .whenOrNull, .whenDataOrNull etc. that you can use to handle null values.

And for error handling, you can use .whenError or .whenDataError to handle errors differently.

Reading with .maybeWhen()

Allows you to handle specific states while providing a default for others:

ref.watch(usersProvider).maybeWhen(
  data: (users) => UserList(users),
  loading: () => CircularProgressIndicator(),
  // Default case for error or any other state
  orElse: () => Text('Something went wrong'),
);

AsyncValue Helpers

If you are working with AsyncNotifierProvider, you can granularly control the states of your provider using AsyncLoading(), AsyncData() and AsyncError() methods.


class UsersProvider extends _$UsersProvider {
  
  Future<List<User>> build() async => await fetchUsers();

  Future<void> fetchUsers() async {
    state = AsyncLoading(); // Set the state to loading

    try {
      final users = await api.getUsers();
      state = AsyncData(users); // Set the state to data
    } catch (e) {
      state = AsyncError(e); // Set the state to error
    }
  }
}

Using AsyncGuard

AsyncValue.guard is a combination of AsyncData and AsyncError. This is useful to avoid having to do a tedious try/catch block.

The above example can be rewritten as:


class UsersProvider extends _$UsersProvider {
  
  Future<List<User>> build() async => await fetchUsers();

  Future<void> fetchUsers() async {
    state = AsyncLoading();

    // This will set the state to either AsyncData or AsyncError
    state = await AsyncValue.guard(() => api.getUsers());
  }
}

Preserving Previous Data

copyWithPrevious is a useful method for preserving previous data while loading new data.

fetchUsers() async {
  // Keep showing old data while loading new data
  state = state.copyWithPrevious(
    AsyncValue.loading()
  );

  final users = await api.getUsers();
  state = AsyncData(users);
}

Best Practices

  1. Always handle all possible states (loading, error, data)
  2. Consider preserving previous data during refreshes
  3. Implement proper error recovery mechanisms
  4. Show appropriate loading states to users

Provider Lifecycle

When working with providers, you often need to control their lifecycle - when they should be created, disposed, or recreated with different parameters.

Riverpod offers powerful modifiers to handle these scenarios efficiently.

Disposing Providers

By default, Riverpod automatically disposes provider instances when they are no longer being listened to.

However, any resources (streams, timers, controllers etc.) created within the provider must be manually disposed.

You can handle resource cleanup using ref.onDispose which will be called when the provider is disposed:


Stream<ChatMessage> chatMessages(Ref ref, String roomId) {
  final socket = WebSocket('wss://chat.example.com/$roomId');

  // Clean up resources when provider is disposed
  ref.onDispose(() {
    socket.close();
  });

  return socket.messages;
}

Common resources requiring manual cleanup:

  • WebSocket connections
  • Stream controllers
  • Timer instances
  • API request cancellation tokens
  • Animation controllers

Let's see some practical examples:

Example 1: API Request Cancellation


Future<List<Product>> searchProducts(Ref ref, String query) async {
  // Create a cancellation token
  final cancelToken = CancelToken();

  // Register cleanup for the cancel token
  ref.onDispose(() {
    cancelToken.cancel();
  });

  // Pass the token to your API call
  return await api.searchProducts(
    query,
    cancelToken: cancelToken
  );
}

Example 2: WebSocket Connection


Stream<ChatMessage> chatMessages(Ref ref, String roomId) {
  final socket = WebSocket('wss://chat.example.com/$roomId');

  // Register cleanup for the WebSocket
  ref.onDispose(() {
    socket.close(); // Explicitly close the WebSocket connection
  });

  return socket.messages.map((data) => ChatMessage.fromJson(data));
}

Example 3: Custom Controller


class Counter extends _$Counter {
  late final Timer _timer;

  
  int build() {
    _timer = Timer.periodic(
      const Duration(seconds: 1),
      (_) => state++,
    );

    // Clean up the timer when the provider is disposed
    ref.onDispose(() {
      _timer.cancel();
    });

    return 0;
  }
}

KeepAlive

Sometimes you may want to preserve state even when no widgets are actively listening to a provider.

This kind of behavior is more common than you think.

Following are some common use cases where you may want to keep a provider alive:

  • Global app settings
  • User authentication state
  • Cached API responses
  • Theme preferences

To prevent auto-disposal of a provider, you can use the keepAlive parameter:

// Notice the riverpod annotation starts with capital 'R'
(keepAlive: true)
class Auth extends _$Auth {
  
  AuthState build() => const AuthState.initial();

  Future<void> login(String username, String password) async {
    // Login state will persist even when no widgets are listening
    state = const AuthState.loading();
    try {
      final user = await _authService.login(username, password);
      state = AuthState.authenticated(user);
    } catch (e) {
      state = AuthState.error(e.toString());
    }
  }
}

If you want to pass parameters to riverpod annotation, you need to use capital 'R' in the annotation as @Riverpod().

WARNING

Use keepAlive sparingly! Each kept-alive provider continues to consume memory. Only use it for truly global state that needs to persist throughout the app's lifecycle.

Passing Parameters to Providers

Riverpod 2 greatly simplified how we pass parameters to providers. You can now pass parameters directly like regular Dart functions.

Basic Parameter Passing


String greeting(Ref ref, {String name = 'Guest'}) {
  return 'Hello, $name!';
}

// Usage:
final greeting1 = ref.watch(greetingProvider(name: 'John')); // "Hello, John!"
final greeting2 = ref.watch(greetingProvider()); // "Hello, Guest!"

Multiple Parameters


Future<List<Product>> filteredProducts(
  Ref ref, {
  required String category,
  String? searchQuery,
  int page = 1,
}) async {
  return await api.getProducts(
    category: category,
    query: searchQuery,
    page: page,
  );
}

// Usage:
final products = ref.watch(filteredProductsProvider(
  category: 'electronics',
  searchQuery: 'phone',
  page: 2,
));

Complex Objects as Parameters


Future<OrderDetails> orderDetails(Ref ref, Order order) async {
  return await api.getOrderDetails(order.id);
}

Once the orderDetailsProvider is created, it will be reused for the same Order object. It will only be recomputed if a different Order object is passed.

TIP

When passing objects as parameters, make sure they properly implement == and hashCode. This ensures the provider correctly determines when to rebuild based on parameter changes.

What's Next?

We've covered a lot of ground in this first part! Let's quickly recap what we learned:

  • The evolution of Riverpod and why certain providers are now recommended over others
  • The five core providers you should focus on in new projects:
    • Provider for simple immutable values
    • FutureProvider for one-time async operations
    • StreamProvider for reactive data streams
    • NotifierProvider for mutable state
    • AsyncNotifierProvider for mutable async state
  • How to read providers using ref.watch, ref.read, and ref.select
  • Handling async states with AsyncValue
  • Managing provider lifecycles with auto-disposal and parameters

TIP

If some concepts still feel unclear, don't worry! The next part will make everything click as we build the todos app.

In Part 2: Building a Riverpod Todo App, we'll put these concepts into practice by building a simple but practical Todo app. You'll learn:

  • Setting up a new Flutter project with Riverpod
  • Creating and consuming providers using the code generation approach
  • Managing todos state with NotifierProvider and AsyncNotifierProvider
  • Building a responsive UI that updates automatically with state changes
  • Understanding provider dependencies in a real application
  • Implementing error handling with ErrorWidget
  • Repository pattern for better code organization

Ready to start building? Let's dive into the Todo app tutorial →