DartFlutter

Why I Use Riverpod (Not Provider) in 2025

20250404 1348 Riverpod Preference 2025 Remix 01jr0a2jehfev9p9sq1gf45tzq 1 1024x683

Flutter has no shortage of state management solutions, but for years, Provider was the go-to. It was simple, effective, and easy to integrate. But in 2025, my default choice is Riverpod, and I haven’t looked back.

In this comprehensive guide, I’ll break down why I prefer Riverpod over Provider with real code comparisons, demonstrate how it solves common development pain points, and show you patterns for building scalable Flutter applications.

Provider Was Great… Until It Wasn’t

Let’s give Provider credit – it taught many of us about Flutter state management. It was easy to use, reactive, and supported scoped state well.

But as my projects grew, Provider started showing limitations:

  • Tight coupling to the widget tree – Providers must be placed above consumers
  • Difficult to reuse logic – Hard to share providers across feature modules
  • Testing requires Flutter – Need WidgetTester for most provider tests
  • Boilerplate for complex patterns – Family providers, async handling
  • Runtime errors – ProviderNotFoundException at runtime, not compile time

Why I Switched to Riverpod

Riverpod is a complete rewrite of Provider by the same author (Remi Rousselet). It keeps the good parts of Provider but fixes the frustrating parts. Let me show you concrete examples.

1. No More Widget-Tree Ties

In Provider, you often had to wrap widgets in MultiProvider, which can become messy:

// Provider: Messy widget tree wrapping
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => AuthProvider()),
        ChangeNotifierProvider(create: (_) => UserProvider()),
        ChangeNotifierProvider(create: (_) => CartProvider()),
        ChangeNotifierProvider(create: (_) => SettingsProvider()),
        // 10 more providers...
        ProxyProvider<AuthProvider, ApiProvider>(
          update: (_, auth, __) => ApiProvider(auth),
        ),
      ],
      child: MaterialApp(home: HomeScreen()),
    );
  }
}

In Riverpod, providers live outside the widget tree:

// Riverpod: Providers defined at file level, not in widget tree
// lib/providers/auth_provider.dart
@riverpod
class Auth extends _$Auth {
  @override
  AuthState build() => const AuthState.initial();

  Future<void> login(String email, String password) async {
    state = const AuthState.loading();
    try {
      final user = await ref.read(authServiceProvider).login(email, password);
      state = AuthState.authenticated(user);
    } catch (e) {
      state = AuthState.error(e.toString());
    }
  }

  void logout() {
    ref.read(authServiceProvider).logout();
    state = const AuthState.initial();
  }
}

// lib/providers/user_provider.dart
@riverpod
Future<User> currentUser(CurrentUserRef ref) async {
  final auth = ref.watch(authProvider);
  return auth.maybeWhen(
    authenticated: (user) => user,
    orElse: () => throw Exception('Not authenticated'),
  );
}

// lib/main.dart - Clean!
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: MaterialApp(home: HomeScreen()),
    );
  }
}

2. Testability Without Flutter

I can test Riverpod providers without needing a WidgetTester or running Flutter-specific code:

// test/providers/auth_provider_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mocktail/mocktail.dart';

class MockAuthService extends Mock implements AuthService {}

void main() {
  late ProviderContainer container;
  late MockAuthService mockAuthService;

  setUp(() {
    mockAuthService = MockAuthService();
    container = ProviderContainer(
      overrides: [
        authServiceProvider.overrideWithValue(mockAuthService),
      ],
    );
  });

  tearDown(() => container.dispose());

  group('AuthProvider', () {
    test('initial state is unauthenticated', () {
      final state = container.read(authProvider);
      expect(state, const AuthState.initial());
    });

    test('login success updates state to authenticated', () async {
      final testUser = User(id: '1', email: 'test@test.com', name: 'Test');
      when(() => mockAuthService.login(any(), any()))
          .thenAnswer((_) async => testUser);

      final notifier = container.read(authProvider.notifier);
      await notifier.login('test@test.com', 'password');

      final state = container.read(authProvider);
      expect(state, AuthState.authenticated(testUser));
    });

    test('login failure updates state to error', () async {
      when(() => mockAuthService.login(any(), any()))
          .thenThrow(Exception('Invalid credentials'));

      final notifier = container.read(authProvider.notifier);
      await notifier.login('test@test.com', 'wrong');

      final state = container.read(authProvider);
      expect(state, isA<AuthStateError>());
    });

    test('logout clears user state', () async {
      // First login
      final testUser = User(id: '1', email: 'test@test.com', name: 'Test');
      when(() => mockAuthService.login(any(), any()))
          .thenAnswer((_) async => testUser);

      final notifier = container.read(authProvider.notifier);
      await notifier.login('test@test.com', 'password');

      // Then logout
      when(() => mockAuthService.logout()).thenAnswer((_) async {});
      notifier.logout();

      final state = container.read(authProvider);
      expect(state, const AuthState.initial());
    });
  });
}

3. Better Async State Handling

With AsyncValue and AsyncNotifier, Riverpod makes handling loading, success, and error states elegant:

// Provider way: Manual state tracking
class ProductsProvider extends ChangeNotifier {
  List<Product>? _products;
  bool _isLoading = false;
  String? _error;

  List<Product>? get products => _products;
  bool get isLoading => _isLoading;
  String? get error => _error;

  Future<void> loadProducts() async {
    _isLoading = true;
    _error = null;
    notifyListeners();

    try {
      _products = await api.getProducts();
    } catch (e) {
      _error = e.toString();
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

// In widget:
Consumer<ProductsProvider>(
  builder: (context, provider, _) {
    if (provider.isLoading) return CircularProgressIndicator();
    if (provider.error != null) return Text('Error: ${provider.error}');
    return ListView.builder(
      itemCount: provider.products?.length ?? 0,
      itemBuilder: (_, i) => ProductCard(provider.products![i]),
    );
  },
)
// Riverpod way: AsyncValue handles it all
@riverpod
Future<List<Product>> products(ProductsRef ref) async {
  final api = ref.read(apiProvider);
  return api.getProducts();
}

// In widget - much cleaner!
class ProductsScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productsProvider);

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

4. Scoped Overrides Are a Superpower

Need to override a provider in tests or a specific part of the app? Riverpod makes this clean and safe:

// Override for testing
void main() {
  testWidgets('shows product list', (tester) async {
    await tester.pumpWidget(
      ProviderScope(
        overrides: [
          productsProvider.overrideWith(
            (ref) async => [
              Product(id: '1', name: 'Test Product', price: 9.99),
            ],
          ),
        ],
        child: const MyApp(),
      ),
    );

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

// Override for specific feature (e.g., admin view)
class AdminDashboard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      overrides: [
        // Admin sees all products, including unpublished
        productsProvider.overrideWith(
          (ref) => ref.read(apiProvider).getAllProducts(includeUnpublished: true),
        ),
      ],
      child: const ProductsScreen(),
    );
  }
}

5. Family Providers for Parameterized State

Riverpod handles parameterized providers elegantly:

// Fetch a specific product by ID
@riverpod
Future<Product> product(ProductRef ref, String id) async {
  final api = ref.read(apiProvider);
  return api.getProduct(id);
}

// Usage in widget
class ProductDetailScreen extends ConsumerWidget {
  final String productId;

  const ProductDetailScreen({required this.productId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productAsync = ref.watch(productProvider(productId));

    return Scaffold(
      appBar: AppBar(
        title: productAsync.whenOrNull(data: (p) => Text(p.name)) ?? 
               const Text('Loading...'),
      ),
      body: productAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => Center(child: Text('Error: $e')),
        data: (product) => ProductDetails(product: product),
      ),
    );
  }
}

// Each productId gets its own cached instance
// ref.watch(productProvider('abc')) // Fetches and caches product abc
// ref.watch(productProvider('xyz')) // Fetches and caches product xyz

6. Compile-Time Safety

Provider throws runtime errors when a provider isn’t found. Riverpod catches these at compile time:

// Provider: Runtime error if AuthProvider not in tree above
final auth = Provider.of<AuthProvider>(context);
// Throws: "Could not find the correct Provider<AuthProvider> above this Widget"

// Riverpod: Compile-time error if provider doesn't exist
final auth = ref.watch(authProvider); // Type-safe, always works

Complete Feature Example: Shopping Cart

Let me show you a complete feature implementation with Riverpod:

// lib/features/cart/providers/cart_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'cart_provider.g.dart';
part 'cart_provider.freezed.dart';

@freezed
class CartItem with _$CartItem {
  const factory CartItem({
    required String productId,
    required String name,
    required double price,
    required int quantity,
  }) = _CartItem;
}

@freezed
class CartState with _$CartState {
  const CartState._();

  const factory CartState({
    @Default([]) List<CartItem> items,
    @Default(false) bool isLoading,
  }) = _CartState;

  double get subtotal => items.fold(0, (sum, item) => sum + item.price * item.quantity);
  double get tax => subtotal * 0.08;
  double get total => subtotal + tax;
  int get itemCount => items.fold(0, (sum, item) => sum + item.quantity);
  bool get isEmpty => items.isEmpty;
}

@riverpod
class Cart extends _$Cart {
  @override
  CartState build() => const CartState();

  void addItem(Product product, {int quantity = 1}) {
    final existingIndex = state.items.indexWhere(
      (item) => item.productId == product.id,
    );

    if (existingIndex != -1) {
      // Update quantity if item exists
      final updatedItems = [...state.items];
      final existing = updatedItems[existingIndex];
      updatedItems[existingIndex] = existing.copyWith(
        quantity: existing.quantity + quantity,
      );
      state = state.copyWith(items: updatedItems);
    } else {
      // Add new item
      state = state.copyWith(
        items: [
          ...state.items,
          CartItem(
            productId: product.id,
            name: product.name,
            price: product.price,
            quantity: quantity,
          ),
        ],
      );
    }
  }

  void removeItem(String productId) {
    state = state.copyWith(
      items: state.items.where((item) => item.productId != productId).toList(),
    );
  }

  void updateQuantity(String productId, int quantity) {
    if (quantity <= 0) {
      removeItem(productId);
      return;
    }

    final updatedItems = state.items.map((item) {
      if (item.productId == productId) {
        return item.copyWith(quantity: quantity);
      }
      return item;
    }).toList();

    state = state.copyWith(items: updatedItems);
  }

  void clear() {
    state = const CartState();
  }

  Future<void> checkout() async {
    state = state.copyWith(isLoading: true);

    try {
      final orderService = ref.read(orderServiceProvider);
      await orderService.createOrder(state.items);
      clear();
    } finally {
      state = state.copyWith(isLoading: false);
    }
  }
}

// Derived providers for specific data
@riverpod
int cartItemCount(CartItemCountRef ref) {
  return ref.watch(cartProvider).itemCount;
}

@riverpod
bool isInCart(IsInCartRef ref, String productId) {
  return ref.watch(cartProvider).items.any(
    (item) => item.productId == productId,
  );
}
// lib/features/cart/screens/cart_screen.dart
class CartScreen extends ConsumerWidget {
  const CartScreen({super.key});

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

    if (cart.isEmpty) {
      return Scaffold(
        appBar: AppBar(title: const Text('Cart')),
        body: const Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.shopping_cart_outlined, size: 64, color: Colors.grey),
              SizedBox(height: 16),
              Text('Your cart is empty'),
            ],
          ),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Cart'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete_outline),
            onPressed: () => ref.read(cartProvider.notifier).clear(),
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: cart.items.length,
              itemBuilder: (_, index) => CartItemTile(
                item: cart.items[index],
              ),
            ),
          ),
          CartSummary(cart: cart),
        ],
      ),
    );
  }
}

class CartItemTile extends ConsumerWidget {
  final CartItem item;

  const CartItemTile({required this.item, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ListTile(
      title: Text(item.name),
      subtitle: Text('\$${item.price.toStringAsFixed(2)} each'),
      trailing: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          IconButton(
            icon: const Icon(Icons.remove),
            onPressed: () => ref
                .read(cartProvider.notifier)
                .updateQuantity(item.productId, item.quantity - 1),
          ),
          Text('${item.quantity}'),
          IconButton(
            icon: const Icon(Icons.add),
            onPressed: () => ref
                .read(cartProvider.notifier)
                .updateQuantity(item.productId, item.quantity + 1),
          ),
        ],
      ),
    );
  }
}

class CartSummary extends ConsumerWidget {
  final CartState cart;

  const CartSummary({required this.cart, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Theme.of(context).cardColor,
        boxShadow: [BoxShadow(blurRadius: 4, color: Colors.black12)],
      ),
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              const Text('Subtotal'),
              Text('\$${cart.subtotal.toStringAsFixed(2)}'),
            ],
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              const Text('Tax'),
              Text('\$${cart.tax.toStringAsFixed(2)}'),
            ],
          ),
          const Divider(),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
              Text(
                '\$${cart.total.toStringAsFixed(2)}',
                style: const TextStyle(fontWeight: FontWeight.bold),
              ),
            ],
          ),
          const SizedBox(height: 16),
          SizedBox(
            width: double.infinity,
            child: ElevatedButton(
              onPressed: cart.isLoading
                  ? null
                  : () => ref.read(cartProvider.notifier).checkout(),
              child: cart.isLoading
                  ? const SizedBox(
                      height: 20,
                      width: 20,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : const Text('Checkout'),
            ),
          ),
        ],
      ),
    );
  }
}

Common Mistakes to Avoid

1. Using ref.read when you should ref.watch

// Wrong: Widget won't rebuild when cart changes
@override
Widget build(BuildContext context, WidgetRef ref) {
  final cart = ref.read(cartProvider); // Use watch!
  return Text('Items: ${cart.itemCount}');
}

// Correct: Widget rebuilds when cart changes
@override
Widget build(BuildContext context, WidgetRef ref) {
  final cart = ref.watch(cartProvider);
  return Text('Items: ${cart.itemCount}');
}

2. Watching in callbacks

// Wrong: Can't watch in callback
onPressed: () {
  final cart = ref.watch(cartProvider); // Error!
}

// Correct: Use read in callbacks
onPressed: () {
  final cart = ref.read(cartProvider);
  ref.read(cartProvider.notifier).clear();
}

3. Not disposing heavy resources

// Wrong: Stream subscription never disposed
@riverpod
Stream<int> counter(CounterRef ref) {
  return Stream.periodic(Duration(seconds: 1), (i) => i);
}

// Correct: Use ref.onDispose for cleanup
@riverpod
Stream<int> counter(CounterRef ref) {
  final controller = StreamController<int>();
  final timer = Timer.periodic(Duration(seconds: 1), (t) {
    controller.add(t.tick);
  });

  ref.onDispose(() {
    timer.cancel();
    controller.close();
  });

  return controller.stream;
}

Why It Matters in 2025

Flutter apps today are more complex than ever – multiple environments, shared logic, dependency injection, test automation. Riverpod is built for scalability:

  • Confidence when refactoring – Compile-time safety catches errors
  • Clean dependency injection – No service locators needed
  • Isolated logic that scales – Feature-based organization
  • Faster tests – Pure Dart, no Flutter required
  • Modern tooling – Code generation reduces boilerplate

Final Thoughts

Provider still works. If you’re building a simple app, there’s nothing wrong with using it. But if you want a better developer experience, cleaner testing, more predictable architecture, and a scalable future – then Riverpod is the state management tool to bet on in 2025.

The code generation with @riverpod annotations reduces boilerplate significantly, the compile-time safety catches bugs before they reach production, and the testing story is so much cleaner than Provider ever was. That’s why I use Riverpod for every new Flutter project.

1 Comment

Leave a Comment