Advanced Riverpod Patterns: Families, Notifiers, and Testing

12 min readMarch 9, 2026
Riverpod advancedRiverpod familiesAsyncNotifier FlutterRiverpod testingriverpod_generatorFlutter state management advancedRiverpod best practicesRiverpod code generation

# Advanced Riverpod Patterns for Production Flutter Apps

Riverpod has become my default state management solution for Flutter, but the gap between "getting started with Riverpod" and "using Riverpod well in production" is significant. This article covers the patterns, pitfalls, and architectural decisions I have refined across multiple shipping apps.

Provider Families and Parameterized Providers

Provider families let you create providers that accept parameters. This is essential when the same data-fetching logic applies to different inputs, like loading a user profile by ID or fetching products by category.

Basic Family Usage

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

class=class="code-string">"code-comment">// In the widget
class UserProfileScreen extends ConsumerWidget {
  final String userId;
  const UserProfileScreen({super.key, required this.userId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider(userId));
    return userAsync.when(
      loading: () => const LoadingIndicator(),
      error: (err, stack) => ErrorDisplay(message: err.toString()),
      data: (user) => UserProfileView(user: user),
    );
  }
}

Complex Parameters with Records

When a provider needs multiple parameters, Dart records provide an elegant solution without creating a dedicated parameter class.

dart
typedef ProductFilter = ({String category, int page, SortOrder sort});

final productsProvider = FutureProvider.autoDispose
    .family<List<Product>, ProductFilter>((ref, filter) async {
  final api = ref.watch(apiClientProvider);
  return api.getProducts(
    category: filter.category,
    page: filter.page,
    sort: filter.sort,
  );
});

class=class="code-string">"code-comment">// Usage
final products = ref.watch(productsProvider((
  category: class="code-string">'electronics',
  page: class="code-number">1,
  sort: SortOrder.priceAsc,
)));

In my production apps, I have found that records strike the perfect balance: they give you named fields without the overhead of a full class, and Riverpod handles their equality comparison correctly out of the box.

StateNotifier and AsyncNotifier Patterns

When StateNotifier Still Makes Sense

Although `Notifier` and `AsyncNotifier` are the recommended approach since Riverpod 2.0, you will encounter `StateNotifier` in existing codebases. Understanding both is important.

dart
class=class="code-string">"code-comment">// Legacy StateNotifier approach
class CartNotifier extends StateNotifier<List<CartItem>> {
  final ApiClient _api;
  CartNotifier(this._api) : super([]);

  Future<void> addItem(Product product) async {
    state = [...state, CartItem.fromProduct(product)];
    await _api.syncCart(state);
  }

  void removeItem(String itemId) {
    state = state.where((item) => item.id != itemId).toList();
  }
}

final cartProvider =
    StateNotifierProvider<CartNotifier, List<CartItem>>((ref) {
  return CartNotifier(ref.watch(apiClientProvider));
});

Modern AsyncNotifier Pattern

`AsyncNotifier` is where Riverpod truly shines for async workflows. The `build` method defines the initial async loading, and subsequent mutations can update state while preserving error handling.

dart
class ProductListNotifier
    extends AsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() async {
    final api = ref.watch(apiClientProvider);
    return api.fetchProducts();
  }

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => build());
  }

  Future<void> deleteProduct(String id) async {
    final previousState = state;
    class=class="code-string">"code-comment">// Optimistic update
    state = AsyncData(
      state.value!.where((p) => p.id != id).toList(),
    );
    try {
      await ref.read(apiClientProvider).deleteProduct(id);
    } catch (e, stack) {
      class=class="code-string">"code-comment">// Rollback on failure
      state = previousState;
      state = AsyncError(e, stack);
    }
  }
}

final productListProvider =
    AsyncNotifierProvider<ProductListNotifier, List<Product>>(
  ProductListNotifier.new,
);

In my production apps, I have found the optimistic update pattern above to be invaluable for creating responsive UIs. Users see immediate feedback while the network request happens in the background, and the state rolls back cleanly if anything goes wrong.

Family + AsyncNotifier Combination

dart
class UserOrdersNotifier
    extends FamilyAsyncNotifier<List<Order>, String> {
  @override
  Future<List<Order>> build(String userId) async {
    final api = ref.watch(apiClientProvider);
    return api.getUserOrders(userId);
  }

  Future<void> cancelOrder(String orderId) async {
    await ref.read(apiClientProvider).cancelOrder(orderId);
    ref.invalidateSelf();
    await future;
  }
}

final userOrdersProvider = AsyncNotifierProvider.family<
    UserOrdersNotifier, List<Order>, String>(
  UserOrdersNotifier.new,
);

When to Use Which Provider Type

Choosing the right provider type is one of the first decisions that trips up developers. Here is the decision table I use.

| Scenario | Provider Type | Why |

|---|---|---|

| Simple computed value | `Provider` | Derived state, no side effects |

| Async data fetch (read-only) | `FutureProvider` | Automatic loading/error/data states |

| Real-time stream | `StreamProvider` | WebSocket, Firestore snapshots |

| Mutable sync state | `NotifierProvider` | Form state, toggles, counters |

| Mutable async state | `AsyncNotifierProvider` | CRUD operations with loading states |

| Parameterized read-only | `FutureProvider.family` | Same query, different inputs |

| Parameterized mutable | `AsyncNotifierProvider.family` | Per-entity operations |

| One-time setup | `Provider` | Dependency injection for services |

When in doubt, start with `FutureProvider` for reads and `AsyncNotifierProvider` for writes. You can always refactor later.

Riverpod Code Generation with riverpod_generator

Code generation reduces boilerplate and catches configuration errors at build time. In my production apps, I have found that adopting codegen from the start saves significant refactoring later.

Setup

yaml
# pubspec.yaml
dependencies:
  flutter_riverpod: ^2.5.0
  riverpod_annotation: ^2.3.0

dev_dependencies:
  riverpod_generator: ^2.4.0
  build_runner: ^2.4.0

Generated Providers in Practice

dart
import class="code-string">'package:riverpod_annotation/riverpod_annotation.dart';

part class="code-string">'auth_provider.g.dart';

class=class="code-string">"code-comment">// Simple provider — replaces Provider<ApiClient>
@riverpod
ApiClient apiClient(ApiClientRef ref) {
  return ApiClient(baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//api.example.com');
}

class=class="code-string">"code-comment">// FutureProvider with family — replaces FutureProvider.family
@riverpod
Future<User> userProfile(UserProfileRef ref, String userId) async {
  final api = ref.watch(apiClientProvider);
  return api.getUser(userId);
}

class=class="code-string">"code-comment">// AsyncNotifier — replaces AsyncNotifierProvider
@riverpod
class TodoList extends _$TodoList {
  @override
  Future<List<Todo>> build() async {
    final api = ref.watch(apiClientProvider);
    return api.fetchTodos();
  }

  Future<void> addTodo(String title) async {
    final api = ref.read(apiClientProvider);
    final newTodo = await api.createTodo(title);
    state = AsyncData([...state.value ?? [], newTodo]);
  }
}

KeepAlive and AutoDispose Control

dart
class=class="code-string">"code-comment">// This provider stays alive for the entire app lifetime
@Riverpod(keepAlive: true)
class AuthState extends _$AuthState {
  @override
  Future<AuthUser?> build() async {
    return ref.watch(authRepositoryProvider).getCurrentUser();
  }
}

class=class="code-string">"code-comment">// This provider is auto-disposed when no longer watched (default)
@riverpod
Future<List<Product>> searchProducts(
  SearchProductsRef ref,
  String query,
) async {
  class=class="code-string">"code-comment">// Debounce the search
  await Future.delayed(const Duration(milliseconds: class="code-number">300));
  if (ref.state is AsyncLoading) return [];

  final api = ref.watch(apiClientProvider);
  return api.searchProducts(query);
}

Testing Providers

Testing is where Riverpod's architecture really pays off. You do not need widget trees, BuildContext, or complex mocking frameworks. A `ProviderContainer` is all you need.

Unit Testing a Notifier

dart
void main() {
  late ProviderContainer container;
  late MockApiClient mockApi;

  setUp(() {
    mockApi = MockApiClient();
    container = ProviderContainer(
      overrides: [
        apiClientProvider.overrideWithValue(mockApi),
      ],
    );
    addTearDown(container.dispose);
  });

  test(class="code-string">'fetchProducts returns product list', () async {
    final expectedProducts = [
      Product(id: class="code-string">'class="code-number">1', name: class="code-string">'Widget A'),
      Product(id: class="code-string">'class="code-number">2', name: class="code-string">'Widget B'),
    ];
    when(() => mockApi.fetchProducts())
        .thenAnswer((_) async => expectedProducts);

    class=class="code-string">"code-comment">// Listen to the provider to trigger the build
    final subscription = container.listen(
      productListProvider,
      (_, __) {},
    );

    class=class="code-string">"code-comment">// Wait for the async operation to complete
    await container.read(productListProvider.future);

    expect(
      container.read(productListProvider).value,
      equals(expectedProducts),
    );

    subscription.close();
  });

  test(class="code-string">'deleteProduct performs optimistic update', () async {
    when(() => mockApi.fetchProducts())
        .thenAnswer((_) async => [Product(id: class="code-string">'class="code-number">1', name: class="code-string">'Test')]);
    when(() => mockApi.deleteProduct(class="code-string">'class="code-number">1'))
        .thenAnswer((_) async {});

    container.listen(productListProvider, (_, __) {});
    await container.read(productListProvider.future);

    await container
        .read(productListProvider.notifier)
        .deleteProduct(class="code-string">'class="code-number">1');

    expect(
      container.read(productListProvider).value,
      isEmpty,
    );
  });
}

Testing with Multiple Overrides

dart
ProviderContainer createTestContainer({
  AuthUser? currentUser,
  List<Product>? products,
}) {
  return ProviderContainer(
    overrides: [
      authStateProvider.overrideWith(() => MockAuthNotifier(currentUser)),
      productListProvider.overrideWith(() => MockProductNotifier(products)),
      analyticsProvider.overrideWithValue(MockAnalytics()),
    ],
  );
}

test(class="code-string">'admin user can delete products', () async {
  final container = createTestContainer(
    currentUser: AuthUser(role: UserRole.admin),
    products: [Product(id: class="code-string">'class="code-number">1', name: class="code-string">'Test')],
  );
  addTearDown(container.dispose);

  final canDelete = container.read(canDeleteProductProvider);
  expect(canDelete, isTrue);
});

Widget Testing with ProviderScope Overrides

dart
testWidgets(class="code-string">'ProductListScreen shows loading then data', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        productListProvider.overrideWith(
          () => FakeProductNotifier([
            Product(id: class="code-string">'class="code-number">1', name: class="code-string">'Test Product'),
          ]),
        ),
      ],
      child: const MaterialApp(home: ProductListScreen()),
    ),
  );

  class=class="code-string">"code-comment">// Initially shows loading
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  class=class="code-string">"code-comment">// After async completes
  await tester.pumpAndSettle();
  expect(find.text(class="code-string">'Test Product'), findsOneWidget);
});

Riverpod + Clean Architecture Integration

Clean Architecture and Riverpod complement each other well when you establish clear boundaries. Here is the structure I use in production.

Layer Separation

lib/
  features/
    products/
      data/
        datasources/
          product_remote_datasource.dart
          product_local_datasource.dart
        models/
          product_model.dart
        repositories/
          product_repository_impl.dart
      domain/
        entities/
          product.dart
        repositories/
          product_repository.dart       # abstract
        usecases/
          get_products.dart
          delete_product.dart
      presentation/
        providers/
          product_providers.dart
        screens/
          product_list_screen.dart
        widgets/
          product_card.dart

Wiring It Up with Providers

dart
class=class="code-string">"code-comment">// Data layer — concrete implementations
@riverpod
ProductRemoteDataSource productRemoteDataSource(
  ProductRemoteDataSourceRef ref,
) {
  final dio = ref.watch(dioProvider);
  return ProductRemoteDataSourceImpl(dio);
}

class=class="code-string">"code-comment">// Domain layer — abstract repository provided via data layer
@riverpod
ProductRepository productRepository(ProductRepositoryRef ref) {
  return ProductRepositoryImpl(
    remote: ref.watch(productRemoteDataSourceProvider),
    local: ref.watch(productLocalDataSourceProvider),
    network: ref.watch(networkInfoProvider),
  );
}

class=class="code-string">"code-comment">// Use cases
@riverpod
GetProducts getProducts(GetProductsRef ref) {
  return GetProducts(ref.watch(productRepositoryProvider));
}

class=class="code-string">"code-comment">// Presentation layer — connects use cases to UI state
@riverpod
class ProductListController extends _$ProductListController {
  @override
  Future<List<Product>> build() async {
    final getProducts = ref.watch(getProductsProvider);
    final result = await getProducts(NoParams());
    return result.fold(
      (failure) => throw failure,
      (products) => products,
    );
  }
}

The key insight is that providers act as your dependency injection container. Each layer only depends on abstractions from the layer below, and Riverpod wires the concrete implementations together.

Common Riverpod Anti-Patterns

1. Using ref.read in build methods

dart
class=class="code-string">"code-comment">// WRONG: ref.read does not trigger rebuilds when the value changes
@override
Widget build(BuildContext context, WidgetRef ref) {
  final user = ref.read(userProvider); class=class="code-string">"code-comment">// Bug: will not update
  return Text(user.name);
}

class=class="code-string">"code-comment">// CORRECT: ref.watch rebuilds the widget when the value changes
@override
Widget build(BuildContext context, WidgetRef ref) {
  final user = ref.watch(userProvider);
  return Text(user.name);
}

2. Watching providers inside callbacks

dart
class=class="code-string">"code-comment">// WRONG: ref.watch inside a callback causes unpredictable behavior
onPressed: () {
  final api = ref.watch(apiProvider); class=class="code-string">"code-comment">// Do not watch in callbacks
  api.submit();
}

class=class="code-string">"code-comment">// CORRECT: ref.read for one-time actions in callbacks
onPressed: () {
  final api = ref.read(apiProvider);
  api.submit();
}

3. Fat providers that do everything

dart
class=class="code-string">"code-comment">// WRONG: one provider handling auth, profile, settings, and notifications
@riverpod
class AppState extends _$AppState {
  @override
  Future<AppData> build() async {
    final user = await fetchUser();
    final settings = await fetchSettings();
    final notifications = await fetchNotifications();
    return AppData(user, settings, notifications);
  }
}

class=class="code-string">"code-comment">// CORRECT: separate concerns into focused providers
@riverpod
Future<User> currentUser(CurrentUserRef ref) async { ... }

@riverpod
Future<Settings> userSettings(UserSettingsRef ref) async { ... }

@riverpod
Future<List<Notification>> notifications(NotificationsRef ref) async { ... }

4. Ignoring autoDispose for navigation-scoped data

dart
class=class="code-string">"code-comment">// WRONG: this provider leaks memory when the user navigates away
final searchResultsProvider = FutureProvider<List<Product>>((ref) async {
  return ref.watch(apiProvider).search(ref.watch(searchQueryProvider));
});

class=class="code-string">"code-comment">// CORRECT: autoDispose cleans up when the screen is no longer visible
final searchResultsProvider =
    FutureProvider.autoDispose<List<Product>>((ref) async {
  return ref.watch(apiProvider).search(ref.watch(searchQueryProvider));
});

5. Circular provider dependencies

dart
class=class="code-string">"code-comment">// WRONG: providerA watches providerB which watches providerA
class=class="code-string">"code-comment">// This causes a ProviderException at runtime

class=class="code-string">"code-comment">// CORRECT: restructure so dependencies flow in one direction
class=class="code-string">"code-comment">// Extract shared logic into a third provider that both can depend on

In my production apps, I have found that anti-pattern number 3 causes the most real-world pain. When one massive provider handles multiple concerns, a change in notifications forces an unnecessary refetch of the user profile. Keeping providers small and focused lets Riverpod's dependency graph do its job efficiently.

Performance Tips

Selective Rebuilds with select

dart
class=class="code-string">"code-comment">// Instead of rebuilding on any user change
final user = ref.watch(userProvider);

class=class="code-string">"code-comment">// Only rebuild when the specific field changes
final userName = ref.watch(
  userProvider.select((user) => user.name),
);

Debouncing Provider Rebuilds

dart
@riverpod
Future<List<Product>> searchProducts(
  SearchProductsRef ref,
  String query,
) async {
  class=class="code-string">"code-comment">// Cancel if a new query arrives within 300ms
  ref.keepAlive();
  final link = ref.keepAlive();
  final timer = Timer(const Duration(seconds: class="code-number">30), link.close);
  ref.onDispose(timer.cancel);

  class=class="code-string">"code-comment">// Debounce
  await Future.delayed(const Duration(milliseconds: class="code-number">300));

  final api = ref.watch(apiClientProvider);
  return api.search(query);
}

Conclusion

Riverpod rewards deliberate architecture. The patterns in this article -- parameterized families, AsyncNotifier with optimistic updates, code generation, focused providers with clean architecture boundaries -- form a toolkit that scales from a small feature to a complex production app. The testing story alone, where a `ProviderContainer` with overrides replaces hundreds of lines of mock setup, makes Riverpod worth the investment.

Start with the decision table to pick the right provider type, adopt code generation early, keep your providers small and focused, and your Flutter apps will be a pleasure to maintain.

Contact me if you need help designing a Riverpod architecture for your Flutter project or migrating an existing codebase to modern Riverpod patterns.

Related Articles

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch