Key Takeaways
- Get your Flutter project’s dependencies right. You’ll need `flutter_riverpod` and `riverpod_generator` for providers that are type-safe and checked at compile time.
- Use `@riverpod` annotations so the code generator can do the work. This cuts way down on boilerplate compared to writing providers manually.
- Implement `ConsumerWidget` or `ConsumerStatefulWidget` to use providers in your UI. This way, only the widgets that depend on a piece of state will rebuild when it changes, which is great for performance.
- Know when to use `ref.watch`, `ref.read`, and `ref.listen`. `watch` is for reactive UI that rebuilds on changes, `read` is for getting a value just once in a callback, and `listen` is for triggering side effects like showing a snackbar or working through.
- Break your app’s state into small, distinct providers. Keep things like authentication, data fetching, and UI state separate to make the app easier to test and maintain down the road.
Let’s be real, state management in Flutter can be a headache. Riverpod is a solid, high-performance option that dodges a lot of the common problems you see in other approaches. It gives you a compile-safe, flexible setup for building even the most complex apps. This is a practical walkthrough on how to get Riverpod integrated and use it for some advanced state management patterns so your codebase doesn’t turn into a mess.
1. Project Setup and Initial Dependencies
First thing’s first, you need to add the right dependencies to your `pubspec.yaml` file. We’re going to add `flutter_riverpod` for the Flutter-specific bindings and `riverpod_generator` to get compile-time safety and avoid boilerplate. Just open your `pubspec.yaml` and drop these lines into the `dependencies` and `dev_dependencies` sections:
dependencies: flutter: sdk: flutter flutter_riverpod: ^2.5.1 # Use the latest stable version riverpod_annotation: ^2.3.5 # Use the latest stable version dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.0 build_runner: ^2.4.8 # Use the latest stable version riverpod_generator: ^2.3.5 # Must match riverpod_annotation version custom_lint: ^0.6.4 # Optional, but recommended for advanced linting riverpod_lint: ^1.4.6 # Optional, but recommended for Riverpod specific linting
After you’ve saved the file, you have to run `flutter pub get` in your terminal. This command pulls down all the packages so your IDE can find them and your app can actually compile.
Pro Tip: Version Management
The Riverpod docs are the source of truth for the latest stable versions of `flutter_riverpod`, `riverpod_annotation`, and the rest. Mismatched versions, especially between `riverpod_annotation` and `riverpod_generator`, cause build errors that are a real pain to track down. Pinning your versions like we did above is a good way to keep your dev environments consistent and avoid those headaches.
Common Mistake: Forgetting `build_runner`
A classic mistake for people new to code-gen in Flutter is forgetting to include `build_runner` in their `dev_dependencies`. You absolutely need it to orchestrate the process that turns your simple `@riverpod` annotations into actual, working provider code. If you forget it, your build will just fail with errors about unresolved annotations.
2. Creating Your First Provider with `riverpod_generator`
The `riverpod_generator` is what makes this whole process so much better. Instead of writing a ton of boilerplate for each provider, you just slap an annotation on a class and let `build_runner` do all the heavy lifting for you. Go ahead and create a new file, say `lib/providers/counter_provider.dart`, and define your first provider like this:
// lib/providers/counter_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart'. Part 'counter_provider.g.dart'; // This file will be generated @riverpod
class Counter extends _$Counter { @override int build() { return 0; // Initial state } void increment() { state++; } void decrement() { state, ; }
}
See that `part ‘counter_provider.g.dart’;` line? That’s telling the build system where the generated code is going to live. The `@riverpod` annotation is what flags the `Counter` class for generation. The `build()` method sets the initial state, and you can see we just modify it directly with `state++` to trigger updates. Now, pop open your terminal and run the code generator:
flutter pub run build_runner build, delete-conflicting-outputs
This command will spit out the `lib/providers/counter_provider.g.dart` file, which contains the `counterProvider` instance you’ll actually use in your UI. And that `, delete-conflicting-outputs` flag is a lifesaver, as it cleans up old, stale generated files before making new ones so you don’t get stuck on cryptic build errors from a previous run.
Pro Tip: Watch Mode for Development
For day-to-day development, you should just use the `watch` command instead: `flutter pub run build_runner watch, delete-conflicting-outputs`. This command keeps the builder running in the background, so it automatically regenerates your provider files whenever you save a change. It saves you from having to manually run the build command over and over again.
3. Integrating Providers into Your UI
To get that `counterProvider` working in your Flutter widgets, you’ll need to wrap your application with a `ProviderScope` and then use either a `ConsumerWidget` or a `ConsumerStatefulWidget` to read the provider’s state. First, let’s edit `main.dart` to add the `ProviderScope`:
// lib/main.dart
import 'package:flutter/material.dart'. Import 'package:flutter_riverpod/flutter_riverpod.dart'. Import 'package:your_app_name/screens/home_screen.dart'; // Assume you create this void main() { runApp( // ProviderScope is where the state of our providers is stored. // All widgets that need to read providers must be descendants of this widget. ProviderScope( child: MyApp(), ), );
} class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Riverpod Counter', theme: ThemeData( primarySwatch: Colors.blue, ), home: HomeScreen(), ); }
}
Now, create your `lib/screens/home_screen.dart` file and use a `ConsumerWidget` to get access to the `counterProvider`:
// lib/screens/home_screen.dart
import 'package:flutter/material.dart'. Import 'package:flutter_riverpod/flutter_riverpod.dart'. Import 'package:your_app_name/providers/counter_provider.g.dart'; // Import the generated file class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // ref.watch listens to changes in the provider's state and rebuilds the widget. final count = ref.watch(counterProvider). Return Scaffold( appBar: AppBar( title: Text('Riverpod Counter Example'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'You have pushed the button this many times:', ), Text( '$count', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), floatingActionButton: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ FloatingActionButton( onPressed: () { // ref.read accesses the provider's notifier to call methods. // It does not cause the widget to rebuild. ref.read(counterProvider.notifier).increment(); }, tooltip: 'Increment', child: Icon(Icons.add), ), SizedBox(height: 10), FloatingActionButton( onPressed: () { ref.read(counterProvider.notifier).decrement(); }, tooltip: 'Decrement', child: Icon(Icons.remove), ), ], ), ); }
}
The `build` method of a `ConsumerWidget` gives you a `WidgetRef` object which is your key to the provider world. You use `ref.watch(counterProvider)` to listen for changes to the counter’s value. When `increment()` or `decrement()` gets called on the `counterProvider.notifier`, only the `Text` widget displaying the count (or any other widget watching that specific provider) will rebuild, not the entire `HomeScreen`. That kind of granular rebuilding is a huge performance win.
Common Mistake: Using `ref.read` for UI Updates
Don’t use `ref.read` when you actually want the UI to update. That’s a super common mistake. `ref.read` gets the value one time and does not subscribe to future changes, so your widget won’t rebuild when the state changes. It’s the right tool for invoking a method on a notifier (like we did with `increment()` and `decrement()`), but it’s the wrong tool for displaying data that’s supposed to be reactive.
4. Asynchronous State Management: Fetching Data
Most apps need to fetch data from an API or do some other kind of async work. Riverpod handles this stuff really well with `AsyncValue` and `AsyncNotifier`. Let’s set up a provider that fetches a list of posts from a fake API. Create a new file `lib/providers/post_provider.dart`:
// lib/providers/post_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart'. Import 'package:http/http.dart' as http. Import 'dart:convert'. Part 'post_provider.g.dart'; // Represents a simple Post model
class Post { final int id. Final String title. Final String body. Post({required this.id, required this.title, required this.body}). Factory Post.fromJson(Map json) { return Post( id: json['id'], title: json['title'], body: json['body'], ); }
} @riverpod
Future> posts(PostsRef ref) async { final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts')). If (response.statusCode == 200) { Iterable l = json.decode(response.body). Return List.from(l.map((model) => Post.fromJson(model))); } else { throw Exception('Failed to load posts'); }
}
Run `flutter pub run build_runner build, delete-conflicting-outputs` again to generate the new provider file. Now you can update your `HomeScreen` to show the posts:
// lib/screens/home_screen.dart (updated)
import 'package:flutter/material.dart'. Import 'package:flutter_riverpod/flutter_riverpod.dart'. Import 'package:your_app_name/providers/counter_provider.g.dart'. Import 'package:your_app_name/providers/post_provider.g.dart'; // Import the generated post provider class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(counterProvider). Final postsAsyncValue = ref.watch(postsProvider); // Watch the async post provider return Scaffold( appBar: AppBar( title: Text('Riverpod Examples'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Counter: $count', style: Theme.of(context).textTheme.headlineMedium, ), SizedBox(height: 20), Expanded( child: postsAsyncValue.when( data: (posts) => ListView.builder( itemCount: posts.length, itemBuilder: (context, index) { final post = posts[index]. Return Card( margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListTile( title: Text(post.title), subtitle: Text(post.body, maxLines: 2, overflow: TextOverflow.ellipsis), ), ); }, ), loading: () => CircularProgressIndicator(), error: (error, stack) => Text('Error: $error'), ), ), ], ), ), floatingActionButton: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ FloatingActionButton( onPressed: () => ref.read(counterProvider.notifier).increment(), tooltip: 'Increment', child: Icon(Icons.add), ), SizedBox(height: 10), FloatingActionButton( onPressed: () => ref.read(counterProvider.notifier).decrement(), tooltip: 'Decrement', child: Icon(Icons.remove), ), ], ), ); }
}
The `postsAsyncValue.when()` method is probably the best part of working with `AsyncValue`. It gives you a clean, declarative block to handle the three states of any async call: `data` for success, `loading` for when it’s in progress, and `error` if something goes wrong. This pattern really cleans up your UI logic for async data and makes it much harder to forget to handle a loading or error state.
Pro Tip: Combining Providers
You can also have providers that depend on other providers. For instance, you could have a `userIdProvider` and then a `userPostsProvider` that uses the current user ID from the first provider to fetch the right posts. You just use the `ref` object inside a provider’s `build` method to read another one. This lets you build a powerful dependency graph where a change in one place, like a user logging out, can automatically trigger updates down the chain. I use this pattern at my current job to manage complex user profiles. A single change in auth status can correctly trigger a cascade of data reloads across the whole app, and Riverpod just handles it.
5. Advanced Provider Types and Modifiers
Riverpod’s got a few more tricks up its sleeve with different provider types and modifiers for specific situations.
- `family` modifier: Lets you create a provider that takes a parameter which is great for fetching a single item by its ID.
// lib/providers/post_provider.dart (added to) @riverpod Future post(PostRef ref, int postId) async { final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/$postId')). If (response.statusCode == 200) { return Post.fromJson(json.decode(response.body)); } else { throw Exception('Failed to load post $postId'); } }
You’d then use it in your widget like `ref.watch(postProvider(1))`. This is perfect for fetching dynamic data based on user actions or navigation arguments.
- `keepAlive` modifier: This tells Riverpod not to destroy a provider’s state when nothing is listening to it anymore. It’s good for global services or caching data.
// lib/providers/auth_service.dart @Riverpod(keepAlive: true) class AuthService extends _$AuthService { @override bool build() { // Simulate an auth check return false; } void login() => state = true. Void logout() => state = false; }
An `authServiceProvider` like this will hold its state even if no widget is currently watching it, which is exactly what you want for something like the app’s main authentication status.
- `ref.listen` for Side Effects: While `ref.watch` is for rebuilding widgets, `ref.listen` is for running some code when a provider’s state changes, without causing a rebuild. It’s the right tool for things like showing snackbars, working through, or logging.
// Inside a ConsumerWidget's build method ref.listen(authServiceProvider, (previous, next) { if (next == true && previous == false) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Logged in successfully!')), ); } });
This approach keeps your UI rendering logic separate from your side effect logic, which makes the code a lot easier to organize and test. Riverpod’s whole design pushes you towards this clear separation of concerns, making your app’s state more predictable. The compile-time safety you get from `riverpod_generator` is a huge bonus, catching bugs before they ever make it to a running app. Once you get comfortable with these patterns, you’ll find you spend a lot more time building features and a lot less time hunting down weird state-related bugs. App performance metrics are tied directly to how well you manage state. If you’re using Riverpod correctly, you’ll find it’s much easier to build a high-performing application. Plus, this structured data flow can inform your larger mobile strategy, especially when you’re dealing with complex data interactions like managing user identity securely which ties into the kind of things discussed in mobile identity and verification rules.
What is the main advantage of Riverpod over other state management solutions in Flutter?
Compile-time safety is the big one. It finds many state-related bugs at build time, not in production. You also don’t need `BuildContext` to access providers, so you can use them anywhere in your code (not just widgets), which makes testing much, much simpler.
How does `ref.watch`, `ref.read`, and `ref.listen` differ?
`ref.watch` rebuilds a widget when state changes. `ref.read` grabs the state once without subscribing to updates, so it’s for use inside callbacks like `onPressed`. `ref.listen` is for side effects, like showing a dialog or working through, that happen in response to a state change but don’t require a widget rebuild.
Is `riverpod_generator` mandatory for using Riverpod?
You can definitely write providers by hand using `Provider`, `StateProvider`, `NotifierProvider`, and so on. But for any real project, `riverpod_generator` is the way to go. It cuts out a ton of boilerplate, gives you better type safety with compile-time checks, and just makes your life easier when you start defining more complex providers.
When should I use `AsyncNotifier` versus a simple `FutureProvider`?
A `FutureProvider` is fine for a simple, one-time data fetch. If you need to re-fetch that data based on user input, or if you need to modify the asynchronous data after it has been loaded (like optimistic updates), you’ll want to use an `AsyncNotifier` or a `Notifier` that holds an `AsyncValue`.
How can I test my Riverpod providers effectively?
They’re very testable because they don’t have a dependency on `BuildContext`. In your tests, you just create a `ProviderContainer`, override any of your provider’s dependencies with mock implementations, and then `read` or `listen` to your provider to check its behavior. This makes unit testing your state logic really straightforward and reliable.