Riverpod in Modern Flutter Applications: Best Practices

Riverpod in Modern Flutter Applications: Best Practices

If you're building a modern Flutter application, managing state and dependencies cleanly is one of the biggest challenges you'll face. Riverpod has emerged as a powerful, compile-safe state management library that addresses many of the shortcomings of its predecessor, Provider. This comprehensive guide will walk you through everything you need to know about using Riverpod in modern Flutter applications, from core concepts to advanced patterns, testing, and performance optimization. By the end, you'll have a solid understanding of how to architect scalable, maintainable, and testable Flutter apps with Riverpod.

  • Riverpod is a reactive state management and dependency injection library for Flutter that provides compile-time safety and testability.
  • It offers various provider types (Provider, StateProvider, StateNotifierProvider, FutureProvider, etc.) to handle different use cases.
  • Riverpod eliminates many common pitfalls of Provider, such as BuildContext dependency and runtime errors.
  • You can use Riverpod with or without code generation, and it integrates well with Flutter's widget lifecycle.
  • Best practices include using autoDispose for memory management, leveraging select for performance, and writing tests with ProviderContainer.

What is Riverpod and Why Should You Use It in Flutter?

Riverpod is a state management library for Flutter and Dart, created by Remi Rousselet as an evolution of the popular Provider package. It stands for 'Riverpod' because it's like Provider but with the 'r' for reactive and 'pod' for the concept of a pod of state. The core idea is to provide a way to share and manage state across your application without relying on BuildContext or widget trees.

The Problems Riverpod Solves

Traditional state management in Flutter often leads to issues like:

  • BuildContext dependency: Provider requires a BuildContext to read values, making it difficult to access state from non-widget code.
  • Runtime errors: Missing providers or incorrect types often result in runtime exceptions rather than compile-time errors.
  • Difficult testing: Testing widgets that depend on providers requires wrapping them in ProviderScope.
  • Global state management: Managing global state without proper disposal can lead to memory leaks.

Riverpod solves these by making providers global, independent of the widget tree, and fully compile-safe. You can access providers from anywhere, and the compiler catches errors like using a provider before it's declared.

Key Benefits of Riverpod

  • Compile-time safety: No more 'ProviderNotFoundException'—the compiler tells you if you miss a provider.
  • No BuildContext needed: Access state from anywhere, including pure Dart classes.
  • Testability: Easily test providers in isolation using ProviderContainer.
  • Auto-dispose: Automatically dispose of state when no longer needed.
  • Flexible provider types: From simple values to complex async state, Riverpod has a provider for it.
  • Hot reload friendly: Works seamlessly with Flutter's hot reload.

Setting Up Riverpod in a Modern Flutter Project

To start using Riverpod, add the dependency to your pubspec.yaml. You can choose between the traditional approach and the code generation approach. We'll cover both.

Adding the Dependency

For the standard approach, add flutter_riverpod to your dependencies.

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.4.0

If you prefer code generation, also add riverpod_annotation and the dev dependencies riverpod_generator and build_runner.

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.4.0
  riverpod_annotation: ^2.3.0

dev_dependencies:
  build_runner: ^2.4.0
  riverpod_generator: ^2.3.0
  custom_lint: ^0.5.0
  riverpod_lint: ^2.3.0

Wrapping Your App with ProviderScope

Every Riverpod application must be wrapped in a ProviderScope widget at the root. This widget stores the state of all providers.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_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: 'Riverpod Demo',
      home: const HomePage(),
    );
  }
}

This minimal setup is all you need to start using Riverpod. The ProviderScope manages the lifecycle of all providers and is essential for the library to function.

Core Concepts: Providers, Consumers, and Ref

Understanding the building blocks of Riverpod is crucial. The three main concepts are providers, consumers, and ref.

Providers: The Source of Truth

A provider is an object that encapsulates a piece of state and exposes it to the rest of the app. Providers are global and immutable, meaning their configuration doesn't change after creation. You can create a provider using the Provider class for simple values, or other classes like StateProvider, FutureProvider, etc.

Here's a simple provider that returns a greeting:

final greetingProvider = Provider<String>((ref) {
  return 'Hello, Riverpod!';
});

Providers can depend on other providers via the ref parameter. This allows for composability and reactive updates.

Consumers: Accessing Providers in Widgets

To read a provider in a widget, you can use ConsumerWidget or ConsumerStatefulWidget. These widgets provide a WidgetRef object that lets you watch providers.

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final greeting = ref.watch(greetingProvider);
    return Text(greeting);
  }
}

When the provider's value changes, the widget rebuilds automatically. You can also use ref.read to read a value without listening for changes, typically inside callbacks.

Ref: The Provider's Connection

The Ref object is passed to every provider's creation function. It allows providers to read other providers, watch for changes, and manage lifecycle. For example, you can use ref.watch to rebuild a provider when a dependency changes.

final nameProvider = StateProvider<String>((ref) => 'Alice');
final greetingProvider = Provider<String>((ref) {
  final name = ref.watch(nameProvider);
  return 'Hello, $name!';
});

Here, greetingProvider will automatically update whenever nameProvider changes.

Exploring Riverpod Provider Types

Riverpod offers a variety of provider types to handle different state management scenarios. Choosing the right one is key to a clean architecture.

Provider

The most basic provider. It returns a value that never changes unless its dependencies change. Use it for dependency injection or computed values.

final apiClientProvider = Provider<ApiClient>((ref) {
  return ApiClient();
});

StateProvider

For simple mutable state. It exposes a StateController that can be updated.

final counterProvider = StateProvider<int>((ref) => 0);

// In a widget:
ref.read(counterProvider.notifier).state++;

StateProvider is great for simple values like toggles, counters, or form inputs.

StateNotifierProvider

For more complex state logic, use StateNotifierProvider with a StateNotifier class. This is ideal for managing state that involves multiple fields and business logic.

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);

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

final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

The notifier exposes methods to mutate the state, and the provider exposes the current value.

FutureProvider

For asynchronous operations that return a single value. It automatically handles loading and error states via AsyncValue.

final userProvider = FutureProvider<User>((ref) async {
  final api = ref.watch(apiClientProvider);
  return api.fetchUser();
});

In the UI, you can use ref.watch(userProvider).when(... to handle data, loading, and error.

StreamProvider

Similar to FutureProvider but for streams. It's perfect for real-time data like Firebase snapshots or WebSocket updates.

final messagesProvider = StreamProvider<List<Message>>((ref) {
  final api = ref.watch(apiClientProvider);
  return api.messageStream();
});

NotifierProvider and AsyncNotifierProvider

With Riverpod 2.0, new provider types like NotifierProvider and AsyncNotifierProvider were introduced. They use the Notifier and AsyncNotifier classes, which provide a more modern and flexible API compared to StateNotifier.

class CounterNotifier extends Notifier<int> {
  @override
  int build() => 0;

  void increment() => state++;
}

final counterProvider = NotifierProvider<CounterNotifier, int>(CounterNotifier.new);

These are now the recommended way to manage state in Riverpod, especially when combined with code generation.

Managing State with Notifier and AsyncNotifier

In modern Riverpod, Notifier and AsyncNotifier are the go-to classes for state management. They offer better ergonomics and are designed to work seamlessly with code generation.

Creating a Notifier

A Notifier is a class that builds a piece of state and exposes methods to modify it. The build method returns the initial state.

class TodosNotifier extends Notifier<List<Todo>> {
  @override
  List<Todo> build() {
    return [];
  }

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

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

final todosProvider = NotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);

This pattern keeps your state logic organized and testable. You can access the notifier via ref.read(todosProvider.notifier) to call methods.

Handling Async State with AsyncNotifier

When your state depends on asynchronous operations, use AsyncNotifier. Its build method returns a Future or Stream, and the state is exposed as AsyncValue.

class UserNotifier extends AsyncNotifier<User> {
  @override
  Future<User> build() async {
    final api = ref.watch(apiClientProvider);
    return api.fetchUser();
  }

  Future<void> updateName(String newName) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final api = ref.read(apiClientProvider);
      final updatedUser = await api.updateUser(name: newName);
      return updatedUser;
    });
  }
}

final userProvider = AsyncNotifierProvider<UserNotifier, User>(UserNotifier.new);

This pattern handles loading, error, and data states elegantly. The UI can use ref.watch(userProvider).when(... to render accordingly.

Dependency Injection and Testing with Riverpod

Riverpod shines when it comes to dependency injection and testing. Because providers are global and can be overridden, you can easily swap implementations for testing or different environments.

Using Providers for Dependency Injection

Instead of passing dependencies through constructors, you can define providers for services like API clients, databases, or repositories. Then, widgets and other providers can read them.

final dioProvider = Provider<Dio>((ref) {
  return Dio(BaseOptions(baseUrl: 'https://api.example.com'));
});

final userRepositoryProvider = Provider<UserRepository>((ref) {
  final dio = ref.watch(dioProvider);
  return UserRepository(dio);
});

This makes dependencies explicit and easy to mock.

Overriding Providers for Tests

In tests, you can override any provider with a mock or fake implementation using ProviderScope overrides.

testWidgets('displays user name', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        userRepositoryProvider.overrideWithValue(FakeUserRepository()),
      ],
      child: MyApp(),
    ),
  );
  // ... assertions
});

For unit testing providers without widgets, use ProviderContainer.

final container = ProviderContainer(
  overrides: [
    userRepositoryProvider.overrideWithValue(FakeUserRepository()),
  ],
);
addTearDown(container.dispose);

final user = await container.read(userProvider.future);
expect(user.name, 'Test User');

This level of testability is one of Riverpod's strongest features.

Performance Considerations and Optimizations

While Riverpod is efficient by default, there are several techniques to optimize performance in large applications.

Using select to Minimize Rebuilds

ref.watch rebuilds a widget whenever any part of the provider's state changes. If you only care about a specific field, use select.

final userName = ref.watch(userProvider.select((user) => user.name));

Now the widget only rebuilds when user.name changes, not when other parts of the user object change.

autoDispose for Memory Management

By default, providers are kept alive even when no longer used. Use autoDispose to automatically dispose of state when there are no more listeners.

final userProvider = FutureProvider.autoDispose<User>((ref) async {
  // ...
});

This is crucial for screens that fetch data and should release resources when closed.

Using family for Parameterized Providers

family allows you to create a provider that depends on an argument, like a user ID. Each unique argument gets its own state.

final userProvider = FutureProvider.family<User, String>((ref, userId) async {
  final api = ref.watch(apiClientProvider);
  return api.fetchUser(userId);
});

This avoids duplicating providers for each ID and enables caching per argument.

Avoiding Unnecessary Rebuilds

  • Use ref.read instead of ref.watch when you don't need to listen for changes.
  • Split large providers into smaller, focused ones.
  • Use Consumer widgets to limit rebuild scope to specific parts of the widget tree.
  • Leverage const widgets where possible.

Best Practices for Scalable Riverpod Applications

Following best practices ensures your codebase remains maintainable as it grows.

Organize Providers by Feature

Group related providers into files or folders. For example, keep all user-related providers in a user_providers.dart file.

Use Code Generation for Consistency

Riverpod's code generation with @riverpod annotations reduces boilerplate and ensures consistency. It generates providers for you, including autoDispose and family.

@riverpod
class Counter extends _$Counter {
  @override
  int build() => 0;

  void increment() => state++;
}

Then run dart run build_runner watch to generate the provider.

Keep Providers Small and Focused

Each provider should do one thing. Avoid massive providers that handle multiple concerns. Compose them instead.

Prefer Notifier/AsyncNotifier Over StateNotifier

In Riverpod 2.0+, Notifier and AsyncNotifier are more powerful and flexible. Use them for new code.

Test Providers in Isolation

Write unit tests for your providers using ProviderContainer. Override dependencies with fakes to test edge cases.

Use Linting with riverpod_lint

Add riverpod_lint to catch common mistakes and enforce best practices.

Common Mistakes and How to Avoid Them

Even experienced developers can fall into traps when using Riverpod. Here are some common pitfalls and how to avoid them.

  • Using ref.watch outside build: ref.watch should only be used inside the build method of a widget or provider. Use ref.read in callbacks.
  • Forgetting autoDispose: Not using autoDispose can lead to memory leaks, especially for providers that fetch data.
  • Overusing StateProvider: For complex state, prefer Notifier/AsyncNotifier to keep logic organized.
  • Not overriding providers in tests: Failing to override dependencies can lead to flaky tests that hit real APIs.
  • Ignoring AsyncValue: Always handle loading and error states when using FutureProvider or StreamProvider.
  • Creating providers inside build methods: Providers should be top-level or static, not created inside widgets, to avoid recreation.
  • Using BuildContext with Riverpod: Riverpod doesn't need BuildContext; using it unnecessarily couples your code to widgets.

Advanced Riverpod Patterns

Once you're comfortable with the basics, you can explore advanced patterns to solve complex problems.

Combining Providers

You can combine multiple providers to derive new state. For example, filtering a list based on a search query.

final todosProvider = NotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);
final searchQueryProvider = StateProvider<String>((ref) => '');

final filteredTodosProvider = Provider<List<Todo>>((ref) {
  final todos = ref.watch(todosProvider);
  final query = ref.watch(searchQueryProvider);
  return todos.where((todo) => todo.title.contains(query)).toList();
});

This reactive composition is where Riverpod truly shines.

Using ProviderScope Overrides for Environments

You can override providers at the root to inject different configurations for development, staging, and production.

void main() {
  runApp(
    ProviderScope(
      overrides: [
        apiBaseUrlProvider.overrideWithValue('https://staging.api.com'),
      ],
      child: MyApp(),
    ),
  );
}

Side Effects with ref.listen

ref.listen allows you to perform side effects when a provider changes, such as showing a snackbar or navigating.

ref.listen<AsyncValue<User>>(userProvider, (previous, next) {
  if (next.hasError) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Error: ${next.error}')),
    );
  }
});

Caching with keepAlive

By default, autoDispose providers are disposed when no longer used. You can keep them alive with ref.keepAlive().

final userProvider = FutureProvider.autoDispose<User>((ref) async {
  final link = ref.keepAlive();
  // Dispose after 30 seconds of inactivity
  final timer = Timer(Duration(seconds: 30), link.close);
  ref.onDispose(timer.cancel);
  // ...
});

Real-World Use Cases for Riverpod

Riverpod is used in production apps of all sizes. Here are some common scenarios where it excels.

Authentication State Management

Managing authentication is a classic use case. You can have a authStateProvider that exposes the current user, and use it to conditionally show login or home screens.

final authStateProvider = StreamProvider<AuthState>((ref) {
  return FirebaseAuth.instance.authStateChanges();
});

In your app, watch this provider to route users appropriately.

Data Fetching and Caching

FutureProvider and AsyncNotifier are perfect for fetching data from APIs. Combined with autoDispose, they can cache data while a screen is active and release it when not needed.

Real-Time Updates with Streams

For chat apps, live scores, or collaborative tools, StreamProvider handles WebSocket or Firebase streams effortlessly. The UI reactively updates as new data arrives.

Form Validation and State

Manage form state with StateProvider or a custom Notifier. Validate inputs and enable/disable submit buttons based on validity.

Theme and Localization

Use providers to manage theme mode and locale, allowing users to switch settings that affect the entire app.

Security Considerations When Using Riverpod

While Riverpod is not a security library, how you manage state can impact your app's security posture. Keep these points in mind.

  • Don't store sensitive data in providers: Avoid keeping passwords, tokens, or personal data in plain state. Use secure storage and only expose what's necessary.
  • Validate on the server: Never trust client-side state for authorization. Always validate on the backend.
  • Use HTTPS: When fetching data in providers, ensure all network calls use HTTPS.
  • Clear state on logout: When a user logs out, dispose of any providers holding user data. Use ref.invalidate or override with fresh state.
  • Be cautious with overrides: Provider overrides in tests are fine, but in production, ensure overrides don't accidentally expose debug features.

Migrating from Provider or Bloc to Riverpod

If you're coming from Provider or Bloc, you might wonder how to transition. Riverpod can coexist with other solutions, so you can migrate incrementally.

From Provider

Provider and Riverpod share similar concepts. You can wrap your existing Provider-based code with Riverpod by creating providers that expose the same values. Gradually replace Provider.of with ref.watch.

From Bloc

Bloc uses streams and events. You can map Bloc states to Riverpod providers. For example, a BlocProvider can be replaced with a StateNotifierProvider that holds the same state. Use ref.listen to react to state changes.

Coexistence

You can use Riverpod alongside Provider or Bloc by wrapping your app in both ProviderScope and MultiProvider or MultiBlocProvider. This allows a gradual migration without rewriting everything at once.

Riverpod vs Provider vs BLoC: A Comparison

Choosing the right state management solution depends on your project's needs. Here's a comparison of Riverpod, Provider, and BLoC.

Riverpod

  • Compile-time safety: Yes.
  • BuildContext dependency: No.
  • Boilerplate: Low to medium (with code generation).
  • Learning curve: Moderate.
  • Testability: Excellent.
  • Best for: Apps of all sizes, especially those needing dependency injection and reactive state.

Provider

  • Compile-time safety: No (runtime errors).
  • BuildContext dependency: Yes.
  • Boilerplate: Low.
  • Learning curve: Easy.
  • Testability: Good but requires widget tests.
  • Best for: Simple apps or those already using Provider.

BLoC

  • Compile-time safety: Partial (events and states are typed).
  • BuildContext dependency: No for the bloc itself, but widgets need context to access.
  • Boilerplate: High.
  • Learning curve: Steep.
  • Testability: Excellent.
  • Best for: Large, complex apps with event-driven architecture.

Riverpod offers a sweet spot between simplicity and power, making it a great default choice for most Flutter projects.

Testing Riverpod Providers in Depth

Testing is a first-class citizen in Riverpod. Let's explore how to write unit tests for providers.

Setting Up a Test Container

Use ProviderContainer to create an isolated environment for your providers.

import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';

void main() {
  test('counter increments', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    expect(container.read(counterProvider), 0);
    container.read(counterProvider.notifier).increment();
    expect(container.read(counterProvider), 1);
  });
}

This test verifies that the counter starts at 0 and increments correctly.

Overriding Dependencies in Tests

You can override any provider to inject fakes.

final fakeApiProvider = Provider<Api>((ref) => FakeApi());

void main() {
  test('fetch user', () async {
    final container = ProviderContainer(
      overrides: [
        apiProvider.overrideWithProvider(fakeApiProvider),
      ],
    );
    addTearDown(container.dispose);

    final user = await container.read(userProvider.future);
    expect(user.name, 'Fake User');
  });
}

This allows you to test your providers without hitting real network endpoints.

Testing AsyncNotifier

AsyncNotifier can be tested by awaiting the future and checking the state.

test('user notifier loads user', () async {
  final container = ProviderContainer(
    overrides: [
      apiProvider.overrideWithValue(FakeApi()),
    ],
  );
  addTearDown(container.dispose);

  final user = await container.read(userProvider.future);
  expect(user, isA<User>());
});

This pattern ensures your async logic works as expected.

Using Riverpod with Freezed and JSON Serialization

For immutable data classes, Freezed is a popular package. You can combine it with Riverpod for robust state management.

@freezed
class User with _$User {
  const factory User({
    required String id,
    required String name,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

Then use it in your providers. Riverpod's code generation works seamlessly with Freezed.

Performance Tuning Riverpod for Large Lists

Rendering large lists efficiently is critical for mobile apps. Riverpod helps by minimizing rebuilds.

Using select with List Items

When displaying a list, avoid watching the entire list if only individual items change.

final todosProvider = NotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);

class TodoItem extends ConsumerWidget {
  final String todoId;
  const TodoItem({required this.todoId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todo = ref.watch(todosProvider.select((todos) =>
      todos.firstWhere((todo) => todo.id == todoId)));
    return ListTile(title: Text(todo.title));
  }
}

This ensures each item only rebuilds when its own data changes.

Using family for Per-Item State

If each list item has its own state (e.g., expanded/collapsed), use a family provider.

final expandedProvider = StateProvider.family<bool, String>((ref, id) => false);

This isolates state per item, preventing unnecessary rebuilds of the entire list.

Frequently Asked Questions About Riverpod in Flutter

What is the difference between Riverpod and Provider?

Riverpod is the successor to Provider. It provides compile-time safety, doesn't depend on BuildContext, and offers more provider types. Provider uses InheritedWidget under the hood, while Riverpod uses a global container.

Do I need to use code generation with Riverpod?

No, code generation is optional. You can write providers manually. However, code generation reduces boilerplate and is recommended for larger projects.

How do I handle loading and error states with Riverpod?

Use AsyncValue when working with FutureProvider or StreamProvider. The when method allows you to provide builders for data, loading, and error states.

Can I use Riverpod with Flutter's BLoC pattern?

Yes, Riverpod and BLoC can coexist. You can use Riverpod for dependency injection and simple state, while keeping BLoC for complex event-driven logic.

Is Riverpod suitable for large applications?

Absolutely. Riverpod is designed for scalability. Its modular nature, testability, and performance optimizations make it ideal for large codebases.

How do I test providers in Riverpod?

Use ProviderContainer to create a test environment. Override dependencies with mocks, then read providers and assert their values. For widget tests, wrap with ProviderScope and override providers as needed.

What is the difference between ref.watch and ref.read?

ref.watch listens for changes and rebuilds the widget or provider. ref.read reads the current value without listening, suitable for callbacks where you don't want to rebuild.

How do I prevent memory leaks with Riverpod?

Use autoDispose on providers that should be disposed when no longer used. You can also manually manage lifecycle with ref.keepAlive() and ref.onDispose().

Final Thoughts and Next Steps

Riverpod is a powerful, modern state management solution for Flutter that addresses many of the pain points of older approaches. Its compile-time safety, testability, and flexibility make it an excellent choice for applications of any size. By understanding providers, consumers, and the various provider types, you can architect clean, maintainable code.

To continue your journey, here are actionable next steps:

  1. Add flutter_riverpod to a new or existing Flutter project and wrap your app in ProviderScope.
  2. Convert a simple piece of state (like a counter) to use StateProvider or NotifierProvider.
  3. Explore FutureProvider and AsyncValue by fetching data from a public API.
  4. Implement a feature with AsyncNotifier and write unit tests using ProviderContainer.
  5. Try code generation with @riverpod annotations to reduce boilerplate.
  6. Add riverpod_lint to your project and follow its recommendations.
  7. Read the official Riverpod documentation and explore community packages.

With practice, Riverpod will become an indispensable tool in your Flutter toolkit. Happy coding!

#riverpod #flutter #state management #dart #provider #dependency injection #async programming #flutter development #mobile development #app development #riverpod providers #flutter state management

Abonnez-vous à notre newsletter

12k+

Abonnés

Hebdomadaire

Fréquence

Gratuit

Toujours