Flutter State Management with Riverpod: A Complete Guide

by Didin J. on Oct 02, 2025 Flutter State Management with Riverpod: A Complete Guide

Learn Flutter state management with Riverpod in this complete guide. Covers setup, providers, async state, persistence, and best practices.

Managing state is one of the most important aspects of building modern Flutter applications. As apps grow in complexity, handling state transitions, dependencies, and data flow can become a real challenge. That’s where Riverpod, a modern and powerful state management library, comes in.

Riverpod is designed to overcome the limitations of the Provider package while maintaining simplicity, type safety, and testability. Whether you’re building a small app or a large production-ready system, Riverpod offers the flexibility and reliability you need.

In this tutorial, we’ll take a deep dive into Flutter state management with Riverpod. You’ll learn the fundamentals, explore practical examples, and discover advanced patterns to take full advantage of Riverpod in your projects.

By the end, you’ll have a clear understanding of:

  • Why Riverpod is a better choice over traditional Provider.

  • How to set up Riverpod in a Flutter project.

  • Managing different types of state (simple, asynchronous, and complex).

  • Using StateProvider, FutureProvider, StreamProvider, and StateNotifierProvider.

  • Best practices and patterns for real-world applications.


Section 1: Why Riverpod?

Before jumping into code, let’s understand why Riverpod exists and how it improves upon other state management solutions.

The Problem with Provider

Provider has been one of the most popular state management solutions in Flutter, but it comes with some limitations:

  • Context-dependent: Accessing providers often requires BuildContext, making some operations less flexible.

  • Complex refactoring: Changing provider types (e.g., ChangeNotifierProvider to StateNotifierProvider) requires significant code changes.

  • Global access issues: Sometimes managing providers across multiple parts of an app becomes tricky.

How Riverpod Solves These Issues

Riverpod was built from the ground up to address these limitations:

  1. Compile-time safety → Riverpod provides better type-safety, catching errors early.

  2. No BuildContext needed → You can read providers anywhere, not just inside widgets.

  3. Refactor-friendly → Switching provider types is straightforward.

  4. Scalability → Ideal for both small and enterprise-level Flutter apps.

  5. Testability → Makes mocking and testing state management much easier.

When to Use Riverpod

  • When building medium to large apps with complex state logic.

  • When you need predictable, testable, and maintainable state management.

  • When you want more control and flexibility compared to the Provider.


Section 2: Project Setup (Flutter + Riverpod Installation)

Before we start coding, let’s set up a new Flutter project and add Riverpod as a dependency.

Step 1: Create a New Flutter Project

Open your terminal and run the following command to create a new Flutter project:

flutter create flutter_riverpod_example

Navigate into the project directory:

cd flutter_riverpod_example

You can open the project in your preferred IDE (VS Code, Android Studio, or IntelliJ).

Step 2: Add Riverpod Dependency

In Flutter, Riverpod comes in two main variants:

  • flutter_riverpod → For Flutter apps (widgets + UI integration).

  • riverpod → For pure Dart projects (backend, CLI tools, etc.).

Since we are building a Flutter app, install flutter_riverpod:

flutter pub add flutter_riverpod

This will add the latest stable version to your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^3.0.1  # (example version, yours may vary)

Step 3: Wrap Your App with ProviderScope

Riverpod requires a ProviderScope at the root of your widget tree. This ensures that all providers can be accessed throughout the app.

Open lib/main.dart and update it like this:

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: 'Flutter Riverpod Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text('Hello Riverpod!'),
      ),
    );
  }
}

At this point, you’ve successfully set up Riverpod in your Flutter project. 🎉


Section 3: Basic Providers (Provider & StateProvider)

Riverpod offers different types of providers depending on your use case. Let’s start with the simplest ones:

  • Provider → Provides read-only values.

  • StateProvider → Provides a mutable state that can be updated.

3.1 Using Provider (Read-Only Values)

The Provider is useful when you just want to expose a value that doesn’t change.

Example: Providing a String

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Step 1: Define a Provider
final helloProvider = Provider<String>((ref) {
  return 'Hello from Riverpod!';
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Step 2: Read the provider
    final message = ref.watch(helloProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Provider Example')),
      body: Center(
        child: Text(message, style: const TextStyle(fontSize: 20)),
      ),
    );
  }
}

What happens here?

  • We defined a Provider that returns a string.

  • Inside HomePage, we used ref.watch(helloProvider) to access its value.

  • The UI displays the value directly.

3.2 Using StateProvider (Mutable State)

StateProvider is the simplest way to manage a piece of mutable state, like a counter.

Example: Counter App with StateProvider

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Step 1: Define a StateProvider
final counterProvider = StateProvider<int>((ref) => 0);

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Step 2: Read the state
    final count = ref.watch(counterProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('StateProvider Example')),
      body: Center(
        child: Text('Count: $count', style: const TextStyle(fontSize: 24)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // Step 3: Update the state
          ref.read(counterProvider.notifier).state++;
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

What happens here?

  • We created a StateProvider<int> that starts at 0.

  • ref.watch(counterProvider) listens to state changes and rebuilds the UI automatically.

  • ref.read(counterProvider.notifier).state++ updates the counter.

When to Use

  • Provider → Use when your value is constant or derived from other providers (read-only).

  • StateProvider → Use for simple, mutable state (like toggles, counters, or text inputs).

🚀 You’ve just learned the basics of Riverpod with Provider and StateProvider.


Section 4: Async Providers (FutureProvider & StreamProvider)

Modern apps often rely on asynchronous data, such as fetching from an API, reading from a database, or listening to a stream of updates. Riverpod provides two providers for these use cases:

  • FutureProvider → For asynchronous data that resolves once (like an HTTP request).

  • StreamProvider → For continuous streams of data (like WebSockets or Firebase).

4.1 Using FutureProvider

FutureProvider is designed for operations that return a Future.

Example: Fetching Data from an API

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;

// Step 1: Define a FutureProvider
final userProvider = FutureProvider<Map<String, dynamic>>((ref) async {
  final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users/1'));
  return json.decode(response.body);
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Step 2: Watch the FutureProvider
    final userAsync = ref.watch(userProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('FutureProvider Example')),
      body: userAsync.when(
        data: (user) => Center(
          child: Text('Name: ${user['name']}', style: const TextStyle(fontSize: 20)),
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(child: Text('Error: $err')),
      ),
    );
  }
}

Key Points:

  • FutureProvider handles loading, success, and error states automatically.

  • The when() method lets you purely handle each state.

4.2 Using StreamProvider

StreamProvider is perfect for real-time data sources such as WebSockets, Firebase, or even a simple Dart Stream.

Example: A Simple Timer Stream

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Step 1: Define a StreamProvider
final timerProvider = StreamProvider<int>((ref) {
  return Stream.periodic(const Duration(seconds: 1), (count) => count);
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Step 2: Watch the StreamProvider
    final timerAsync = ref.watch(timerProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('StreamProvider Example')),
      body: timerAsync.when(
        data: (value) => Center(
          child: Text('Seconds elapsed: $value', style: const TextStyle(fontSize: 24)),
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(child: Text('Error: $err')),
      ),
    );
  }
}

Key Points:

  • StreamProvider listens to the stream and rebuilds the UI on every new value.

  • Like FutureProvider, it has built-in loading, data, and error handling.

When to Use

  • FutureProvider → For one-time async operations (e.g., API requests, local storage fetch).

  • StreamProvider → For continuous updates (e.g., timers, sockets, Firebase Firestore).

🎯 With FutureProvider and StreamProvider, you can now handle asynchronous state elegantly in your Flutter apps.


Section 5: StateNotifierProvider & ChangeNotifierProvider

For more complex state management scenarios, Riverpod provides providers that let you manage structured and reactive state in a separate class, making your code more maintainable and testable.

Two commonly used options are:

  • StateNotifierProvider → Manages state using an immutable state object.

  • ChangeNotifierProvider → Similar to Provider’s ChangeNotifier, but with Riverpod’s improvements.

5.1 Using StateNotifierProvider

StateNotifierProvider is ideal when you need:

  • More control over state updates.

  • Complex logic (like authentication, form validation, or managing lists).

  • Immutable state (better for predictability).

Example: Todo List with StateNotifier

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Step 1: Define a state model
class Todo {
  final String title;
  final bool completed;

  Todo({
    required this.title,
    this.completed = false,
  });

  Todo copyWith({String? title, bool? completed}) {
    return Todo(
      title: title ?? this.title,
      completed: completed ?? this.completed,
    );
  }
}

// Step 2: Create a StateNotifier
class TodoNotifier extends StateNotifier<List<Todo>> {
  TodoNotifier() : super([]);

  void add(String title) {
    state = [...state, Todo(title: title)];
  }

  void toggle(int index) {
    final todo = state[index];
    state = [
      for (int i = 0; i < state.length; i++)
        if (i == index) todo.copyWith(completed: !todo.completed) else state[i],
    ];
  }
}

// Step 3: Define a StateNotifierProvider
final todoProvider = StateNotifierProvider<TodoNotifier, List<Todo>>((ref) {
  return TodoNotifier();
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todos = ref.watch(todoProvider);
    final controller = TextEditingController();

    return Scaffold(
      appBar: AppBar(title: const Text('StateNotifierProvider Example')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: TextField(
              controller: controller,
              decoration: const InputDecoration(
                labelText: 'Add a todo',
                border: OutlineInputBorder(),
              ),
              onSubmitted: (value) {
                if (value.isNotEmpty) {
                  ref.read(todoProvider.notifier).add(value);
                  controller.clear();
                }
              },
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: todos.length,
              itemBuilder: (context, index) {
                final todo = todos[index];
                return ListTile(
                  title: Text(
                    todo.title,
                    style: TextStyle(
                      decoration: todo.completed ? TextDecoration.lineThrough : null,
                    ),
                  ),
                  trailing: Checkbox(
                    value: todo.completed,
                    onChanged: (_) {
                      ref.read(todoProvider.notifier).toggle(index);
                    },
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

Key Points:

  • StateNotifier keeps logic separate from UI.

  • State updates are immutable (state = newValue).

  • Easy to test and maintain.

5.2 Using ChangeNotifierProvider

If you already use ChangeNotifier (from Provider package), Riverpod provides a bridge with ChangeNotifierProvider.

Example: Counter with ChangeNotifier

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Step 1: Define a ChangeNotifier
class CounterNotifier extends ChangeNotifier {
  int count = 0;

  void increment() {
    count++;
    notifyListeners();
  }
}

// Step 2: Define a ChangeNotifierProvider
final counterProvider = ChangeNotifierProvider<CounterNotifier>((ref) {
  return CounterNotifier();
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('ChangeNotifierProvider Example')),
      body: Center(
        child: Text('Count: ${counter.count}', style: const TextStyle(fontSize: 24)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(counterProvider).increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Key Points:

  • Useful when migrating from Provider.

  • Still works with Riverpod’s ref.watch and ref.read.

  • But StateNotifierProvider is preferred for new projects (cleaner & immutable state).

When to Use

  • StateNotifierProvider → Preferred for new projects, complex logic, and immutable state.

  • ChangeNotifierProvider → Good for migrating existing Provider-based code to Riverpod.

🎯 With these providers, you can now manage structured and scalable state in your apps.


Section 6: Scoped & Family Providers

Sometimes, you’ll want to create parameterized providers (providers that depend on an argument) or limit a provider’s scope to a specific part of the widget tree. Riverpod gives us two powerful tools for this:

  • Family Providers → Allows passing parameters into a provider.

  • Scoped Providers → Lets you override providers locally in a widget subtree.

6.1 Family Providers

The family modifier lets you pass arguments to a provider at runtime.

Example: Fetching a User by ID

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;

// Step 1: Create a FutureProvider.family
final userProvider = FutureProvider.family<Map<String, dynamic>, int>((ref, userId) async {
  final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users/$userId'));
  return json.decode(response.body);
});

class UserDetailPage extends ConsumerWidget {
  final int userId;
  const UserDetailPage({super.key, required this.userId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Step 2: Pass parameter when watching
    final userAsync = ref.watch(userProvider(userId));

    return Scaffold(
      appBar: AppBar(title: Text('User $userId')),
      body: userAsync.when(
        data: (user) => Padding(
          padding: const EdgeInsets.all(16),
          child: Text('Name: ${user['name']}\nEmail: ${user['email']}',
              style: const TextStyle(fontSize: 18)),
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(child: Text('Error: $err')),
      ),
    );
  }
}

Key Points:

  • FutureProvider.family allows different user IDs to fetch different data.

  • Useful for API requests, filtering, or item-specific state.

6.2 Scoped Providers (Overrides)

Scoped providers let you override the value of a provider in a specific widget subtree.

Example: Local Counter Override

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Step 1: Define a provider
final greetingProvider = Provider<String>((ref) => 'Hello from Global!');

class ScopedExamplePage extends StatelessWidget {
  const ScopedExamplePage({super.key});

  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      overrides: [
        // Step 2: Override the provider for this subtree
        greetingProvider.overrideWithValue('Hello from Scoped Provider!'),
      ],
      child: const ScopedChild(),
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final message = ref.watch(greetingProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Scoped Provider Example')),
      body: Center(
        child: Text(message, style: const TextStyle(fontSize: 20)),
      ),
    );
  }
}

Key Points:

  • Scoped providers are local overrides.

  • Useful for testing, theming, or customizing behavior in specific screens.

  • The override affects only the subtree wrapped with ProviderScope.

When to Use

  • Family Providers → When your state/data depends on parameters (IDs, filters, etc.).

  • Scoped Providers → When you need different behaviors/values in specific parts of your app.

🎯 Now you know how to pass parameters into providers and override providers locally for more flexibility.


Section 7: Combining Providers & Computed State

In real-world applications, you’ll often need to derive new values based on multiple providers. Riverpod makes this straightforward with its reactive provider system.

You can:

  • Combine multiple providers to compute new values.

  • Automatically rebuild UI when dependencies change.

  • Keep your business logic separate from widgets.

7.1 Combining Providers

Let’s say we have two providers and we want to compute a result based on both.

Example: Full Name Provider

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Step 1: Define base providers
final firstNameProvider = Provider<String>((ref) => 'John');
final lastNameProvider = Provider<String>((ref) => 'Doe');

// Step 2: Define a computed provider
final fullNameProvider = Provider<String>((ref) {
  final firstName = ref.watch(firstNameProvider);
  final lastName = ref.watch(lastNameProvider);
  return '$firstName $lastName';
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final fullName = ref.watch(fullNameProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Computed Provider Example')),
      body: Center(
        child: Text(fullName, style: const TextStyle(fontSize: 24)),
      ),
    );
  }
}

Key Points:

  • fullNameProvider automatically recomputes if either firstNameProvider or lastNameProvider changes.

  • Great for derived state (like total price, full name, formatted values).

7.2 Combining Async Providers

You can also combine async providers (Future/Stream).

Example: Combine Two FutureProviders

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;

// Step 1: Define FutureProviders
final userProvider = FutureProvider<Map<String, dynamic>>((ref) async {
  final res = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users/1'));
  return json.decode(res.body);
});

final postProvider = FutureProvider<Map<String, dynamic>>((ref) async {
  final res = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
  return json.decode(res.body);
});

// Step 2: Combine providers
final combinedProvider = FutureProvider<Map<String, dynamic>>((ref) async {
  final user = await ref.watch(userProvider.future);
  final post = await ref.watch(postProvider.future);

  return {
    'userName': user['name'],
    'postTitle': post['title'],
  };
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final combinedAsync = ref.watch(combinedProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Combining Providers Example')),
      body: combinedAsync.when(
        data: (data) => Padding(
          padding: const EdgeInsets.all(16),
          child: Text(
            '${data['userName']} wrote:\n\n"${data['postTitle']}"',
            style: const TextStyle(fontSize: 18),
          ),
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(child: Text('Error: $err')),
      ),
    );
  }
}

Key Points:

  • ref.watch(...future) lets you wait for async values.

  • Computed provider updates automatically when dependencies change.

  • Useful for joined data (e.g., user + posts, cart items + prices).

7.3 Best Use Cases for Computed State

  • Combining user info + preferences.

  • Calculating totals (cart, scores, analytics).

  • Formatting or transforming data for display.

  • Aggregating results from multiple APIs.

🎯 With computed and combined providers, you can create reactive, declarative logic that keeps your UI in sync with state changes.


Section 8: Persisting State with Riverpod

In many apps, you’ll want state to persist across sessions—for example, saving user settings, authentication tokens, or preferences. Riverpod doesn’t provide persistence by itself, but it integrates easily with storage solutions such as:

  • SharedPreferences → Key-value storage for small data.

  • Hive → Lightweight NoSQL database for Flutter.

  • SQLite / Drift → Relational database for structured data.

In this section, we’ll use SharedPreferences to persist a simple state with Riverpod.

8.1 Example: Persisting a Theme Setting

Let’s say we want to toggle between light mode and dark mode, and keep that preference saved.

Step 1: Add Dependency

Run:

flutter pub add shared_preferences

Step 2: Create a Notifier with Persistence

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

// Step 1: Create a StateNotifier for theme mode
class ThemeNotifier extends StateNotifier<ThemeMode> {
  ThemeNotifier() : super(ThemeMode.light) {
    _loadTheme();
  }

  // Load theme from SharedPreferences
  Future<void> _loadTheme() async {
    final prefs = await SharedPreferences.getInstance();
    final isDark = prefs.getBool('isDark') ?? false;
    state = isDark ? ThemeMode.dark : ThemeMode.light;
  }

  // Toggle theme and save it
  Future<void> toggleTheme() async {
    final prefs = await SharedPreferences.getInstance();
    final isDark = state == ThemeMode.dark;
    state = isDark ? ThemeMode.light : ThemeMode.dark;
    await prefs.setBool('isDark', !isDark);
  }
}

// Step 2: Create a provider
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>((ref) {
  return ThemeNotifier();
});

Step 3: Use the Provider in the App

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeMode = ref.watch(themeProvider);

    return MaterialApp(
      title: 'Flutter Riverpod Persistence',
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      themeMode: themeMode, // 👈 Apply persisted theme
      home: const ThemePage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeNotifier = ref.read(themeProvider.notifier);

    return Scaffold(
      appBar: AppBar(title: const Text('Persisted Theme Example')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => themeNotifier.toggleTheme(),
          child: const Text('Toggle Theme'),
        ),
      ),
    );
  }
}

How it works:

  • On startup, _loadTheme() retrieves the saved preference.

  • When the button is pressed, it toggleTheme() updates both the Riverpod state and the SharedPreferences.

  • The app theme persists across restarts.

8.2 Other Persistence Options

  • Hive → Store structured objects (great for offline apps).

  • Drift/SQLite → Store relational data (complex schemas).

  • Secure Storage → Store sensitive data like tokens.

🎯 With persistence integrated, your Riverpod state can now survive app restarts, making your apps user-friendly and production-ready.


Section 9: Best Practices for Riverpod

Riverpod makes state management powerful and maintainable, but to keep your code clean, scalable, and production-ready, it’s important to follow some best practices.

9.1 Structure Your Providers Clearly

  • Keep providers in separate files (e.g., auth_provider.dart, theme_provider.dart).

  • Group related providers into feature modules to avoid a messy main.dart.

✅ Example project structure:

lib/
 ├─ main.dart
 ├─ providers/
 │   ├─ auth_provider.dart
 │   ├─ theme_provider.dart
 │   └─ counter_provider.dart
 └─ screens/
     ├─ home_page.dart
     └─ login_page.dart

9.2 Avoid Over-Watching Providers

  • Use ref.watch() only where UI updates are necessary.

  • Use ref.read() for one-time actions (e.g., button clicks, async triggers).

✅ Example:

// Good practice
final counter = ref.watch(counterProvider); // UI updates
ref.read(counterProvider.notifier).increment(); // Action only

9.3 Keep Business Logic in Notifiers

  • Don’t mix business logic directly into widgets.

  • Encapsulate state changes inside StateNotifier or Notifier classes.

✅ Example:

// BAD ❌
onPressed: () => ref.read(counterProvider.notifier).state++;

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

  void increment() => state++;
}

9.4 Handle Errors Gracefully

  • Use AsyncValue (AsyncValue.data, .loading, .error) to manage network state.

  • Show friendly error messages instead of crashing.

✅ Example:

ref.watch(userProvider).when(
  data: (user) => Text('Hello ${user.name}'),
  loading: () => const CircularProgressIndicator(),
  error: (err, _) => Text('Error: $err'),
);

9.5 Use ref.listen for Side Effects

  • For navigation, dialogs, or snackbars, prefer ref.listen() instead of building UI directly from provider changes.

✅ Example:

ref.listen<AuthState>(authProvider, (prev, next) {
  if (next.isLoggedIn) {
    Navigator.pushReplacementNamed(context, '/home');
  }
});

9.6 Test Your Providers

  • Riverpod makes providers test-friendly.

  • Use ProviderContainer in unit tests.

✅ Example test:

void main() {
  test('counter increments', () {
    final container = ProviderContainer();
    final notifier = container.read(counterProvider.notifier);

    notifier.increment();

    expect(container.read(counterProvider), 1);
  });
}

9.7 Optimize Performance

  • Prefer StateNotifierProvider for a complex state instead of StateProvider.

  • Use select to watch only parts of the state:

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

This reduces unnecessary rebuilds.

9.8 Persistence & Security

  • Use SharedPreferences or Hive for general persistence.

  • Use flutter_secure_storage for sensitive data like tokens.

📌 Following these best practices ensures your Riverpod-powered app stays scalable, performant, and maintainable as it grows.


Section 10: Conclusion + Final Thoughts

In this tutorial, we explored Flutter state management with Riverpod, starting from the basics all the way to advanced use cases. Here’s a quick recap of what we covered:

  • Setup: How to add Riverpod and configure your project with ProviderScope.

  • Basic Providers: Using Provider and StateProvider for a simple state.

  • Async Providers: Managing network requests with FutureProvider and StreamProvider.

  • StateNotifier & Notifier: Encapsulating business logic and creating reusable state management patterns.

  • Scoped Overrides: Providing different values for providers in different parts of the app.

  • Persistence: Storing state across app restarts with SharedPreferences or Hive.

  • Best Practices: Organizing providers, avoiding unnecessary rebuilds, handling errors gracefully, and writing tests.

Why Riverpod?

Riverpod is not just a replacement for Provider — it’s a more robust, testable, and flexible approach to managing state in Flutter. Its declarative API, compile-time safety, and ability to handle both synchronous and asynchronous state make it a top choice for production apps.

When to Use Riverpod

  • Small apps need cleaner state management.

  • Medium-to-large apps require complex state handling, dependency injection, and testability.

  • Teams are looking for a future-proof, well-maintained state management solution.

Final Thoughts

Riverpod strikes an excellent balance between simplicity and power. By following the patterns and best practices from this guide, you can confidently build apps that are scalable, maintainable, and efficient.

If you want to go further, here are some ideas:

  • Combine Riverpod with Flutter Hooks for even cleaner widget logic.

  • Explore code generation with riverpod_generator for less boilerplate.

  • Apply Riverpod to real-world features like authentication, caching, or theming.

🎉 Congratulations! You now have a solid understanding of Riverpod in Flutter. Go ahead and start applying it to your next Flutter project.

You can find the example source code on our GitHub.

That's just the basics. If you need more deep learning about Flutter and Dart, you can take the following cheap course:

Thanks!