Advanced Riverpod Patterns Every Flutter Developer Should Know
You know how to create a provider and watch it in a widget. Great. But production Flutter apps need more than the basics. They need patterns for search, pagination, performance, and real-world state transitions. Let's level up.
Single-Action Controllers: The Golden Rule
The most important Riverpod pattern is also the simplest: one controller, one job.
BAD: ProductController with create, update, delete, favorite, archive
GOOD: Separate controllers for each action:
- ToggleTaskController (toggle completion)
- DeleteTaskController (delete a task)
- AddTaskController (add a task)
Why? Because each controller has its own loading and error states. When ToggleTaskController is loading, you can show a spinner on the checkbox without affecting the delete button. If you cram everything into one controller, a loading state for "delete" also disables "toggle."
@riverpod
class ToggleTaskController extends _$ToggleTaskController {
@override
FutureOr<void> build() {}
Future<void> toggleTaskCompletion(int dayNumber, int taskIndex) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final service = ref.read(disciplineServiceProvider);
await service.toggleTaskCompletion(dayNumber, taskIndex);
ref.invalidate(disciplineControllerProvider);
});
}
}
Name your controllers by their job, not by their entity. ToggleTaskController, not TaskController.
Debounced Search That Actually Works
Every search implementation needs debouncing, but most tutorials get it wrong. Here's the clean Riverpod way:
@riverpod
class SearchQuery extends _$SearchQuery {
@override
String build() => '';
void update(String query) => state = query;
}
@riverpod
Future<List<Item>> searchResults(Ref ref) async {
final query = ref.watch(searchQueryProvider);
if (query.trim().isEmpty) return []; // No API call for empty query
// Debounce: wait 300ms after last keystroke
await Future.delayed(const Duration(milliseconds: 300));
final service = ref.read(itemServiceProvider);
return service.search(query);
}
The magic: when the user types another character, Riverpod auto-disposes the old provider and starts a new one. The 300ms delay in the old provider never completes. You get automatic cancellation for free.
Pagination Without Losing Your List
The trickiest part of pagination is loading the next page without replacing the current list. If you set state = AsyncLoading(), you lose all visible items. Users hate that.
@riverpod
class PaginatedListController extends _$PaginatedListController {
static const _pageSize = 20;
@override
Future<PaginatedState<Item>> build() async {
final result = await ref.watch(itemServiceProvider).fetchItems(limit: _pageSize);
return PaginatedState(
items: result.items,
hasMore: result.items.length >= _pageSize,
nextCursor: result.nextCursor,
);
}
Future<void> loadMore() async {
final current = state.valueOrNull;
if (current == null || current.isLoadingMore || !current.hasMore) return;
// Use a flag, NOT AsyncLoading
state = AsyncData(current.copyWith(isLoadingMore: true));
try {
final result = await ref.read(itemServiceProvider).fetchItems(
limit: _pageSize,
cursor: current.nextCursor,
);
state = AsyncData(current.copyWith(
items: [...current.items, ...result.items],
isLoadingMore: false,
hasMore: result.items.length >= _pageSize,
nextCursor: result.nextCursor,
));
} catch (e) {
state = AsyncData(current.copyWith(isLoadingMore: false, error: e.toString()));
}
}
}
Key insight: use isLoadingMore: true in your state, not AsyncLoading. This keeps existing items visible while the spinner shows at the bottom.
Preventing Rebuild Cascades
The most common performance problem in Riverpod isn't slow providers. It's wide dependency scope - one widget watching too many providers.
// BAD - ANY of these changing rebuilds the ENTIRE widget
class DashboardScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
final stats = ref.watch(statsProvider);
final theme = ref.watch(themeProvider);
return Column(children: [
Header(name: user.name), // rebuilds when stats change
StatsCard(stats: stats), // rebuilds when user changes
ThemeBadge(theme: theme), // rebuilds when stats change
]);
}
}
Fix: push ref.watch down to leaf widgets.
// GOOD - each widget watches only what it needs
class DashboardScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Column(children: [
const DashboardHeader(), // watches userProvider only
const DashboardStats(), // watches statsProvider only
const ThemeBadge(), // watches themeProvider only
]);
}
}
class DashboardHeader extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return Text(user.name);
}
}
Now when statsProvider changes, only DashboardStats rebuilds. Zero wasted work.
Side Effects Done Right
This is where most developers make mistakes. The rule is simple:
ref.watchis for rendering (inbuild())ref.listenis for reacting (also inbuild(), but for side effects)ref.readis for one-time actions (in callbacks)
class TaskScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// Side effect: show error dialog when action fails
ref.listen(toggleTaskControllerProvider, (prev, next) {
next.showAlertDialogOnError(context);
});
// Navigate on success (check the TRANSITION, not just the value)
ref.listen(saveControllerProvider, (prev, next) {
if (prev?.isLoading == true && next.hasValue && !next.isLoading) {
Navigator.of(context).pop();
}
});
// Rendering
final tasks = ref.watch(taskListProvider);
return tasks.when(
data: (data) => TaskList(tasks: data),
loading: () => const CircularProgressIndicator(),
error: (e, st) => ErrorView(error: e),
);
}
}
The transition check (prev?.isLoading == true) is critical. Without it, the listener fires on initial build when state goes from null to AsyncData(void), causing premature navigation.
The keepAlive Decision Tree
Not sure whether a provider should persist or auto-dispose? Follow this:
Is it a database, SDK client, or platform service?
YES -> keepAlive: true (expensive singleton)
NO -> Does it hold app-wide state that survives navigation?
YES -> keepAlive: true
NO -> Does it cache static data that never changes mid-session?
YES -> keepAlive: true
NO -> Use @riverpod (auto-dispose, the default)
Most providers should auto-dispose. Only use keepAlive for singletons and static data.
Stale-While-Revalidate
Show old data while loading new data. Users see content immediately instead of a loading spinner:
Future<void> refresh() async {
// Keep showing old data during reload
state = const AsyncLoading<AnalyticsData>().copyWithPrevious(state);
state = await AsyncValue.guard(() async {
return ref.read(analyticsServiceProvider).calculateAnalytics();
});
}
copyWithPrevious is the secret weapon. The widget sees isLoading: true AND hasValue: true simultaneously, so it can show a subtle spinner over the existing content.
The Naming Convention That Saves Your Sanity
Name everything by its job, and make method names specific:
// GOOD - specific verb + target
controller.searchById(userId);
controller.toggleTaskCompletion(dayNumber, taskIndex);
controller.syncLocalToRemote();
// BAD - generic verbs that could mean anything
controller.execute();
controller.run();
controller.handle();
controller.process();
If a method name doesn't tell you what it does without reading the implementation, rename it.
What You Should Do Next
Pick one pattern from this article and apply it to your current project. Start with single-action controllers - they give you the most immediate benefit with the least refactoring. Then move to rebuild prevention. Then pagination and search.
Each pattern is independent. You don't need to adopt them all at once. But once you start using them, you'll wonder how you ever built apps without them.



