DEV Community

Brian Ting
Brian Ting

Posted on

Flutter State Management Made Simple: Mastering Riverpod in Practice (Part 2)

Implementation Deep Dive

Riverpod’s true power shines when you start implementing it in real-world scenarios. Let’s move beyond theory and explore how to wield this framework effectively—from basic setups to advanced patterns.

1. Basic Implementation

The Gateway: ProviderScope

Every Riverpod-powered app starts with ProviderScope, a widget that injects the state container into your app. Wrap your root widget with it:

void main() {  
  runApp(  
    ProviderScope(  
      child: MyApp(),  
    ),  
  );  
}  
Enter fullscreen mode Exit fullscreen mode

This acts as the backbone for all providers in your app. Providers are declarative, reusable objects that manage and provide state or dependencies across a Flutter application, allowing efficient and clean state management.

Three Pillars of Providers

1. StateProvider: Ideal for ephemeral state (e.g., counters, switches).
   final counterProvider = StateProvider<int>((ref) => 0);  
Enter fullscreen mode Exit fullscreen mode

Why this works: Exposes a simple state getter/setter.

2. StateNotifierProvider: Manages complex business logic with immutable state.
   class TodoNotifier extends StateNotifier<List<Todo>> {  
     TodoNotifier() : super([]);  
     void addTodo(Todo todo) => state = [...state, todo];  
   }  
   final todoProvider = StateNotifierProvider<TodoNotifier, List<Todo>>((ref) => TodoNotifier());  
Enter fullscreen mode Exit fullscreen mode

Why this works: Decouples logic from UI, enabling testability.

3. FutureProvider: Handles async operations like API calls.
   final weatherProvider = FutureProvider<Weather>((ref) async {  
     final location = ref.watch(locationProvider);  
     return await WeatherAPI.fetch(location);  
   });  
Enter fullscreen mode Exit fullscreen mode

Why this works: Automatically manages loading/error states.

Example: From setState to Riverpod—A Paradigm Shift

Compare a counter implementation:

// Traditional setState  
class CounterPage extends StatefulWidget {  
  @override  
  _CounterPageState createState() => _CounterPageState();  
}  

// Riverpod approach  
class CounterPage extends ConsumerWidget {  
  @override  
  Widget build(BuildContext context, WidgetRef ref) {  
    final count = ref.watch(counterProvider);  
    return ElevatedButton(  
      onPressed: () => ref.read(counterProvider.notifier).state++,  
      child: Text('$count'),  
    );  
  }  
}  
Enter fullscreen mode Exit fullscreen mode

Riverpod eliminates the need for StatefulWidget by managing state outside the widget tree. State becomes globally accessible through WidgetRef, making it easier to access and modify from anywhere in your app while maintaining clean architecture. This approach reduces boilerplate code and separates state management concerns from your UI logic.


2. Advanced Scenarios

Dependency Injection Made Simple

Think of providers as containers for your services. Here's how to manage API calls cleanly:

// Define your API client provider - a single source of truth
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());

// Use it to fetch user data
final userProvider = FutureProvider<User>((ref) async {
  final client = ref.watch(apiClientProvider);
  return client.fetchUser();
});

// Usage in a widget
class UserProfile extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userProvider);
    return user.when(
      data: (data) => Text(data.name),
      loading: () => CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Key takeaway: Providers create a clean dependency injection system that makes testing straightforward - simply override providers with mocks without changing your production code.

State Persistence

Save app settings automatically across restarts by pairing StateNotifierProvider with HydratedMixin for automatic serialization, requiring minimal additional code:

// Define your settings state
class Settings {
  final ThemeMode theme;
  Settings({required this.theme});

  factory Settings.defaults() => Settings(theme: ThemeMode.system);

  Settings copyWith({ThemeMode? theme}) =>
      Settings(theme: theme ?? this.theme);

  factory Settings.fromJson(Map<String, dynamic> json) =>
      Settings(theme: ThemeMode.values[json['theme'] as int]);

  Map<String, dynamic> toJson() => {'theme': theme.index};
}

// Create a persistent provider
final settingsProvider = StateNotifierProvider<SettingsNotifier, Settings>(
  (ref) => SettingsNotifier(),
);

class SettingsNotifier extends StateNotifier<Settings> with HydratedMixin {
  SettingsNotifier() : super(Settings.defaults());

  void updateTheme(ThemeMode theme) => state = state.copyWith(theme: theme);

  @override
  Settings fromJson(Map<String, dynamic> json) => Settings.fromJson(json);

  @override
  Map<String, dynamic> toJson(Settings state) => state.toJson();
}

// Usage in a widget
class ThemeToggle extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final settings = ref.watch(settingsProvider);
    return Switch(
      value: settings.theme == ThemeMode.dark,
      onChanged: (isDark) => ref.read(settingsProvider.notifier)
          .updateTheme(isDark ? ThemeMode.dark : ThemeMode.light),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Combining Providers

Calculate values based on multiple states:

// Shopping cart example
final cartItemsProvider = StateNotifierProvider<CartNotifier, List<CartItem>>(...);
final discountProvider = StateProvider<double>((ref) => 0.0);

final cartTotalProvider = Provider<double>((ref) {
  final items = ref.watch(cartItemsProvider);
  final discount = ref.watch(discountProvider);

  final subtotal = items.fold(0.0, (sum, item) => sum + item.price * item.quantity);
  return subtotal * (1 - discount);
});
Enter fullscreen mode Exit fullscreen mode

Key takeaway: Create reactive computed states that automatically update when their dependencies change - perfect for derived values like totals, filtered lists, or complex calculations.

Parameterized Providers with Family

Fetch different data instances using the same provider:

final productProvider = FutureProvider.family<Product, String>((ref, productId) async {
  final client = ref.watch(apiClientProvider);
  return client.fetchProduct(productId);
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final product = ref.watch(productProvider(productId));
    return product.when(
      data: (data) => ListTile(title: Text(data.name)),
      loading: () => Shimmer(),
      error: (error, _) => ErrorTile(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

When to use: Perfect for scenarios where you need multiple instances of similar data, like fetching different products by ID or managing user-specific states.


3. Common Pitfalls & Best Practices

Reactivity Traps

  • Don’t: Overuse ref.read in builders—it breaks reactivity.
  // 🚫 Avoid  
  ref.read(counterProvider.notifier).state++;  

  // ✅ Prefer  
  ref.watch(counterProvider);  
Enter fullscreen mode Exit fullscreen mode
  • Do: Use autoDispose to avoid memory leaks:
  final tempDataProvider = StateProvider.autoDispose<int>((ref) => 0);  
Enter fullscreen mode Exit fullscreen mode

Optimize Rebuilds with select

Prevent unnecessary widget rebuilds by listening to specific properties:

final userName = ref.watch(userProvider.select((user) => user.name));  
Enter fullscreen mode Exit fullscreen mode

Only rebuilds when user.name changes, not the entire user object.


4. Testing

Building on our earlier discussion of dependency injection, here's how to put provider overrides into practice:

testWidgets('Displays mocked user', (tester) async {  
  await tester.pumpWidget(  
    ProviderScope(  
      overrides: [  
        userProvider.overrideWithValue(  
          AsyncValue.data(User(name: 'Test User')),  
        ),  
      ],  
      child: MyApp(),  
    ),  
  );  
  expect(find.text('Test User'), findsOneWidget);  
});  
Enter fullscreen mode Exit fullscreen mode

No complex mocking setup—swap dependencies at the provider level.


Wrapping Up

Riverpod isn't just about managing state—it's about architecting scalable, testable, and maintainable apps. By embracing providers, families, and reactive patterns, you'll unlock a toolkit that grows with your app's complexity.

As you continue your Flutter development journey, remember that solid state management isn't just a technical choice—it's an investment in your app's future growth and your team's productivity. I hope these articles have equipped you with the knowledge and confidence to build more robust Flutter applications with Riverpod.

Top comments (0)