Flutter Riverpod 2.0 State Management Guide

State management is the most debated topic in Flutter development, and for good reason. The wrong choice costs weeks of refactoring later. After evaluating Provider, BLoC, GetX, and Riverpod across production apps, Riverpod 2.0 has emerged as the clear winner for most projects in 2023.

Riverpod 2.0, released late last year, brings code generation, improved type safety, and a dramatically simplified API. If you tried Riverpod 1.x and found it verbose, version 2.0 deserves a fresh look. This guide covers everything you need to build production-ready Flutter apps with Riverpod 2.0.

Why Riverpod Over the Alternatives

Before diving into implementation, let me address why Riverpod 2.0 specifically:

Provider (the package, not the concept) works well for simple apps but struggles with complex dependency graphs. It relies on the widget tree for scoping, which creates tight coupling between your state management and UI hierarchy.

BLoC provides excellent structure but introduces significant boilerplate. Every feature requires Events, States, and BLoC classes. For teams that value explicit state machines, BLoC is solid. For teams that value productivity, it can feel heavy.

GetX offers simplicity but at the cost of testability and explicit dependency management. Its global state approach makes it difficult to reason about data flow in large applications.

Riverpod 2.0 combines the best aspects: compile-time safety, framework independence (providers exist outside the widget tree), excellent testability, and with code generation, minimal boilerplate.

Setting Up

Riverpod 2.0

Add the required dependencies to your pubspec.yaml:

dependencies:
  flutter_riverpod: ^2.2.0
  riverpod_annotation: ^2.0.0

dev_dependencies:
  riverpod_generator: ^2.1.0
  build_runner: ^2.3.0

Wrap your app with ProviderScope:

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

Code Generation: The

Code Generation: The New Standard Infographic New Standard

The most significant change in Riverpod 2.0 is code generation. Instead of manually choosing between Provider, StateProvider, FutureProvider, StreamProvider, StateNotifierProvider, and NotifierProvider, you write annotated functions or classes and the generator creates the correct provider type.

Simple Providers

For computed values or dependencies without state:

import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'providers.g.dart';

@riverpod
String greeting(GreetingRef ref) {
  return 'Hello from Riverpod 2.0';
}

@riverpod
double taxRate(TaxRateRef ref) {
  return 0.10; // Australian GST
}

@riverpod
double priceWithTax(PriceWithTaxRef ref, double price) {
  final tax = ref.watch(taxRateProvider);
  return price * (1 + tax);
}

Run dart run build_runner build and Riverpod generates the appropriate provider types automatically. No more choosing between provider variants.

Async Providers

For data fetched from APIs or databases:

@riverpod
Future<List<Product>> products(ProductsRef ref) async {
  final repository = ref.watch(productRepositoryProvider);
  return repository.fetchProducts();
}

@riverpod
Stream<List<Message>> chatMessages(ChatMessagesRef ref, String roomId) {
  final chatService = ref.watch(chatServiceProvider);
  return chatService.messagesStream(roomId);
}

The generator recognises Future and Stream return types and creates FutureProvider and StreamProvider instances respectively. Parameters are handled automatically, creating family providers behind the scenes.

Stateful Providers with Notifier

For mutable state with business logic, use the class-based syntax:

@riverpod
class CartNotifier extends _$CartNotifier {
  @override
  List<CartItem> build() {
    return [];
  }

  void addItem(Product product) {
    state = [
      ...state,
      CartItem(
        product: product,
        quantity: 1,
        addedAt: DateTime.now(),
      ),
    ];
  }

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

  void updateQuantity(String productId, int quantity) {
    state = state.map((item) {
      if (item.product.id == productId) {
        return item.copyWith(quantity: quantity);
      }
      return item;
    }).toList();
  }

  double get total => state.fold(
        0,
        (sum, item) => sum + (item.product.price * item.quantity),
      );
}

The build method replaces the constructor-based initialisation from Riverpod 1.x. It defines the initial state and is called whenever the provider is first read or needs to rebuild.

Async Notifiers

For stateful providers that need to fetch initial data:

@riverpod
class UserProfile extends _$UserProfile {
  @override
  Future<User> build() async {
    final authService = ref.watch(authServiceProvider);
    final userId = authService.currentUserId;
    final repository = ref.watch(userRepositoryProvider);
    return repository.getUser(userId);
  }

  Future<void> updateName(String name) async {
    final repository = ref.watch(userRepositoryProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final updated = await repository.updateUser(
        state.requireValue.copyWith(name: name),
      );
      return updated;
    });
  }
}

Working with AsyncValue

AsyncValue is Riverpod’s way of representing asynchronous state. It has three states: AsyncData, AsyncLoading, and AsyncError. The when method provides exhaustive pattern matching:

class ProductListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productsProvider);

    return productsAsync.when(
      data: (products) => ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) => ProductCard(
          product: products[index],
        ),
      ),
      loading: () => const Center(
        child: CircularProgressIndicator(),
      ),
      error: (error, stack) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Something went wrong: $error'),
            ElevatedButton(
              onPressed: () => ref.invalidate(productsProvider),
              child: const Text('Retry'),
            ),
          ],
        ),
      ),
    );
  }
}

AsyncValue Best Practices

Preserve previous data during refresh. When refreshing, you often want to show the old data with a loading indicator rather than replacing the entire screen with a spinner:

productsAsync.when(
  data: (products) => RefreshIndicator(
    onRefresh: () => ref.refresh(productsProvider.future),
    child: ProductList(products: products),
  ),
  loading: () => const LoadingScreen(),
  error: (e, s) => ErrorScreen(error: e, onRetry: () => ref.invalidate(productsProvider)),
  // Keep showing old data while refreshing
  skipLoadingOnRefresh: true,
);

Use AsyncValue.guard for state mutations in async notifiers. It automatically catches exceptions and wraps them in AsyncError:

Future<void> deleteProduct(String id) async {
  state = await AsyncValue.guard(() async {
    await repository.delete(id);
    return state.requireValue.where((p) => p.id != id).toList();
  });
}

Provider Lifecycle and Disposal

Understanding when providers are created and disposed is critical for resource management:

@riverpod
class WebSocketConnection extends _$WebSocketConnection {
  @override
  Stream<ServerEvent> build() {
    final channel = WebSocketChannel.connect(
      Uri.parse('wss://api.example.com/events'),
    );

    // Cleanup when provider is disposed
    ref.onDispose(() {
      channel.sink.close();
    });

    return channel.stream.map(
      (data) => ServerEvent.fromJson(jsonDecode(data)),
    );
  }
}

By default, generated providers are auto-disposed when no longer listened to. If you need a provider to persist, use the keepAlive annotation:

@Riverpod(keepAlive: true)
class AuthState extends _$AuthState {
  @override
  Future<AuthUser?> build() async {
    return authService.getCurrentUser();
  }
}

Testing with Riverpod

Testing is where Riverpod truly shines. You can override any provider in tests without modifying your production code:

void main() {
  test('CartNotifier adds items correctly', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    final cart = container.read(cartNotifierProvider.notifier);

    cart.addItem(testProduct);

    expect(container.read(cartNotifierProvider).length, 1);
    expect(container.read(cartNotifierProvider).first.product, testProduct);
  });

  test('Products provider returns data from repository', () async {
    final container = ProviderContainer(
      overrides: [
        productRepositoryProvider.overrideWithValue(
          MockProductRepository(),
        ),
      ],
    );
    addTearDown(container.dispose);

    final products = await container.read(productsProvider.future);

    expect(products.length, 3);
  });
}

Widget tests work similarly with ProviderScope overrides:

testWidgets('ProductListScreen shows products', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        productsProvider.overrideWith(
          (ref) => Future.value(testProducts),
        ),
      ],
      child: const MaterialApp(
        home: ProductListScreen(),
      ),
    ),
  );

  await tester.pumpAndSettle();

  expect(find.text('Test Product'), findsOneWidget);
});

Architecture Patterns with Riverpod

Repository Pattern

Structure your data layer with repository providers:

@riverpod
ProductRepository productRepository(ProductRepositoryRef ref) {
  final dio = ref.watch(dioProvider);
  final db = ref.watch(databaseProvider);
  return ProductRepositoryImpl(dio: dio, database: db);
}

Feature-Based Organisation

Organise providers by feature rather than type:

lib/
  features/
    products/
      data/
        product_repository.dart
      domain/
        product.dart
      presentation/
        product_list_screen.dart
        product_providers.dart
    cart/
      data/
        cart_repository.dart
      domain/
        cart_item.dart
      presentation/
        cart_screen.dart
        cart_providers.dart
  shared/
    providers/
      dio_provider.dart
      database_provider.dart

Combining Providers

One of Riverpod’s strengths is composing providers. A provider can watch other providers to create derived state:

@riverpod
Future<HomeScreenData> homeScreenData(HomeScreenDataRef ref) async {
  final products = await ref.watch(featuredProductsProvider.future);
  final user = await ref.watch(userProfileProvider.future);
  final cart = ref.watch(cartNotifierProvider);

  return HomeScreenData(
    featuredProducts: products,
    userName: user.name,
    cartItemCount: cart.length,
  );
}

Migration from Provider or Riverpod 1.x

If you are migrating from the Provider package, the conceptual shift is moving state management outside the widget tree. Providers become global declarations, and you use ref.watch and ref.read instead of context.watch and context.read.

If you are migrating from Riverpod 1.x, the key change is adopting code generation. The StateNotifier is replaced by Notifier (and AsyncNotifier), and you no longer need to manually choose provider types. Migration can be incremental — old-style and new-style providers coexist within the same app.

Practical Recommendations

  1. Always use code generation for new projects. The reduction in boilerplate and type safety improvements are worth the build step.
  2. Start with functional providers and move to class-based Notifiers only when you need mutable state with methods.
  3. Use ref.watch in build methods and ref.read in callbacks. This is the single most important rule for avoiding stale state bugs.
  4. Invalidate rather than reset. When you need to refresh data, ref.invalidate(provider) triggers a clean rebuild.
  5. Keep providers focused. One provider per concern. Combine them with derived providers when needed.

Riverpod 2.0 represents the maturation of Flutter state management. It is no longer a question of whether Riverpod is production-ready — it is the standard that other solutions are measured against.


Building a Flutter app? Our team at eawesome delivers cross-platform mobile applications with robust, scalable architecture.