Ahmet Balaman LogoAhmet Balaman

Flutter: Modern State Management with Riverpod

personAhmet Balaman
calendar_today
FlutterRiverpodState ManagementStateNotifierProvider

Riverpod is a modern state management solution developed by Remi Rousselet, the creator of Provider, that overcomes all the limitations of Provider. It offers compile-time safety, testability, and flexibility.

Why is State Management Important?

Modern applications have complex data flows:

  • User login and session management
  • Instant messaging and notifications
  • Offline data synchronization
  • Multiple API integrations
  • Real-time updates

A powerful state management solution is essential to manage this complexity.

Limitations of Provider

Provider is a great tool, but it has some limitations:

1. BuildContext Dependency

// You always need context to read Provider
final user = Provider.of<UserProvider>(context);

// You can't access it in places without context (utility functions, services)

2. Runtime Errors

// If Provider is not found, the app throws error AT RUNTIME
// Cannot be caught at compile time
final data = context.read<SomeProvider>(); // ProviderNotFoundException!

3. Provider Combination Difficulty

It becomes complex when one provider depends on another provider.

4. Hot Reload Issues

Providers sometimes don't update properly on hot reload.

5. Global State Access

Accessing state from outside the widget tree is difficult.

How Riverpod Solves These Problems

✅ No BuildContext Needed

// Riverpod: Access from anywhere without context
final user = ref.watch(userProvider);

// Works in service classes, tests, everywhere

✅ Compile-Time Safety

// If provider is not found, code WON'T COMPILE
// You catch errors before running
final data = ref.watch(someProvider); // Compile-time check!

✅ Easy Provider Combination

// A provider can easily watch other providers
final userOrdersProvider = FutureProvider((ref) async {
  final user = await ref.watch(userProvider.future);
  return fetchOrders(user.id);
});

✅ Excellent Testability

// You can easily override providers
ProviderScope(
  overrides: [
    userProvider.overrideWithValue(mockUser),
  ],
  child: MyApp(),
)

✅ Memory Management with AutoDispose

Unused providers are automatically cleaned up.

When Should You Use Riverpod?

Situation Provider Riverpod
Simple apps
Large/Enterprise projects ⚠️
Many dependent providers
Heavy testing requirements ⚠️
Access outside widget tree
New projects ⚠️

General Rule: If you're starting a new project or have complex state requirements, prefer Riverpod.

Provider vs Riverpod

Feature Provider Riverpod
BuildContext Required Not needed
Compile-time safety Limited Full
Provider combination Hard Easy
Testability Medium Excellent
Hot reload Problematic Seamless
Global access With BuildContext From anywhere

⚠️ Important Note: Riverpod is an external package and does not work in DartPad. To run the examples on your computer, follow the installation steps below.

Installation

Add to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.4.9

Then run in terminal:

flutter pub get

Basic Setup

Wrap your application with ProviderScope:

import 'package:flutter_riverpod/flutter_riverpod.dart';

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

Provider Types

1. Provider (Immutable Values)

// Simple value
final greetingProvider = Provider<String>((ref) {
  return 'Hello Flutter!';
});

// Usage
class MyWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final greeting = ref.watch(greetingProvider);
    return Text(greeting);
  }
}

2. StateProvider (Simple State)

// Counter state
final counterProvider = StateProvider<int>((ref) => 0);

// Usage
class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    
    return Scaffold(
      body: Center(child: Text('$count')),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(counterProvider.notifier).state++;
        },
        child: Icon(Icons.add),
      ),
    );
  }
}

3. StateNotifierProvider (Complex State)

// State class
class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);
  
  void increment() => state++;
  void decrement() => state--;
  void reset() => state = 0;
}

// Provider definition
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

// Usage
class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    
    return Column(
      children: [
        Text('$count'),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).increment(),
          child: Text('Increment'),
        ),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).decrement(),
          child: Text('Decrement'),
        ),
      ],
    );
  }
}

4. FutureProvider (Async Data)

final userProvider = FutureProvider<User>((ref) async {
  final response = await http.get(Uri.parse('https://api.example.com/user'));
  return User.fromJson(jsonDecode(response.body));
});

// Usage
class UserPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider);
    
    return userAsync.when(
      loading: () => CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
      data: (user) => Text('Hello ${user.name}'),
    );
  }
}

5. StreamProvider (Stream Data)

final messagesProvider = StreamProvider<List<Message>>((ref) {
  return FirebaseFirestore.instance
      .collection('messages')
      .snapshots()
      .map((snapshot) => snapshot.docs.map((doc) => Message.fromDoc(doc)).toList());
});

// Usage
class MessagesPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final messagesAsync = ref.watch(messagesProvider);
    
    return messagesAsync.when(
      loading: () => CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
      data: (messages) => ListView.builder(
        itemCount: messages.length,
        itemBuilder: (context, index) => ListTile(
          title: Text(messages[index].text),
        ),
      ),
    );
  }
}

ConsumerWidget vs Consumer

ConsumerWidget (Entire widget)

class MyPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}

Consumer (Only a section)

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Page')), // Won't rebuild
      body: Consumer(
        builder: (context, ref, child) {
          final count = ref.watch(counterProvider);
          return Text('$count'); // Only this rebuilds
        },
      ),
    );
  }
}

ref.watch vs ref.read

// ✅ Use watch in build method (listens to changes)
Widget build(BuildContext context, WidgetRef ref) {
  final count = ref.watch(counterProvider);
  return Text('$count');
}

// ✅ Use read in event handlers (one-time read)
onPressed: () {
  ref.read(counterProvider.notifier).increment();
}

// ❌ Don't use read in build method
Widget build(BuildContext context, WidgetRef ref) {
  final count = ref.read(counterProvider); // Won't update
  return Text('$count');
}

Real World Example: Todo App

// Todo model
class Todo {
  final String id;
  final String title;
  final bool isCompleted;
  
  Todo({required this.id, required this.title, this.isCompleted = false});
  
  Todo copyWith({String? title, bool? isCompleted}) {
    return Todo(
      id: id,
      title: title ?? this.title,
      isCompleted: isCompleted ?? this.isCompleted,
    );
  }
}

// Todo Notifier
class TodoNotifier extends StateNotifier<List<Todo>> {
  TodoNotifier() : super([]);
  
  void addTodo(String title) {
    state = [
      ...state,
      Todo(id: DateTime.now().toString(), title: title),
    ];
  }
  
  void toggleTodo(String id) {
    state = state.map((todo) {
      if (todo.id == id) {
        return todo.copyWith(isCompleted: !todo.isCompleted);
      }
      return todo;
    }).toList();
  }
  
  void removeTodo(String id) {
    state = state.where((todo) => todo.id != id).toList();
  }
}

// Provider
final todoProvider = StateNotifierProvider<TodoNotifier, List<Todo>>((ref) {
  return TodoNotifier();
});

// Filter provider
enum TodoFilter { all, completed, uncompleted }

final todoFilterProvider = StateProvider<TodoFilter>((ref) => TodoFilter.all);

// Filtered todo list
final filteredTodosProvider = Provider<List<Todo>>((ref) {
  final todos = ref.watch(todoProvider);
  final filter = ref.watch(todoFilterProvider);
  
  switch (filter) {
    case TodoFilter.completed:
      return todos.where((todo) => todo.isCompleted).toList();
    case TodoFilter.uncompleted:
      return todos.where((todo) => !todo.isCompleted).toList();
    case TodoFilter.all:
      return todos;
  }
});

// UI
class TodoPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todos = ref.watch(filteredTodosProvider);
    
    return Scaffold(
      appBar: AppBar(
        title: Text('Tasks'),
        actions: [
          PopupMenuButton<TodoFilter>(
            onSelected: (filter) {
              ref.read(todoFilterProvider.notifier).state = filter;
            },
            itemBuilder: (context) => [
              PopupMenuItem(value: TodoFilter.all, child: Text('All')),
              PopupMenuItem(value: TodoFilter.completed, child: Text('Completed')),
              PopupMenuItem(value: TodoFilter.uncompleted, child: Text('Uncompleted')),
            ],
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: todos.length,
        itemBuilder: (context, index) {
          final todo = todos[index];
          return ListTile(
            leading: Checkbox(
              value: todo.isCompleted,
              onChanged: (_) {
                ref.read(todoProvider.notifier).toggleTodo(todo.id);
              },
            ),
            title: Text(
              todo.title,
              style: TextStyle(
                decoration: todo.isCompleted 
                    ? TextDecoration.lineThrough 
                    : null,
              ),
            ),
            trailing: IconButton(
              icon: Icon(Icons.delete),
              onPressed: () {
                ref.read(todoProvider.notifier).removeTodo(todo.id);
              },
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddDialog(context, ref),
        child: Icon(Icons.add),
      ),
    );
  }
  
  void _showAddDialog(BuildContext context, WidgetRef ref) {
    final controller = TextEditingController();
    
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('New Task'),
        content: TextField(
          controller: controller,
          decoration: InputDecoration(hintText: 'Task name'),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('Cancel'),
          ),
          ElevatedButton(
            onPressed: () {
              if (controller.text.isNotEmpty) {
                ref.read(todoProvider.notifier).addTodo(controller.text);
                Navigator.pop(context);
              }
            },
            child: Text('Add'),
          ),
        ],
      ),
    );
  }
}

Provider Combination

// User provider
final userProvider = FutureProvider<User>((ref) async {
  return await fetchUser();
});

// User's orders (depends on userProvider)
final userOrdersProvider = FutureProvider<List<Order>>((ref) async {
  final user = await ref.watch(userProvider.future);
  return await fetchOrders(user.id);
});

// Total order amount
final totalOrderAmountProvider = Provider<double>((ref) {
  final ordersAsync = ref.watch(userOrdersProvider);
  
  return ordersAsync.when(
    loading: () => 0,
    error: (_, __) => 0,
    data: (orders) => orders.fold(0, (sum, order) => sum + order.amount),
  );
});

Family Modifier

Creating parameterized providers:

// Product detail provider
final productProvider = FutureProvider.family<Product, String>((ref, productId) async {
  final response = await http.get(
    Uri.parse('https://api.example.com/products/$productId'),
  );
  return Product.fromJson(jsonDecode(response.body));
});

// Usage
class ProductPage extends ConsumerWidget {
  final String productId;
  
  ProductPage({required this.productId});
  
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productAsync = ref.watch(productProvider(productId));
    
    return productAsync.when(
      loading: () => CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
      data: (product) => Text(product.name),
    );
  }
}

AutoDispose Modifier

Automatic provider cleanup:

// Provider is cleaned up when widget is disposed
final searchProvider = FutureProvider.autoDispose<List<Product>>((ref) async {
  // For debounce
  await Future.delayed(Duration(milliseconds: 500));
  
  // Cancellation check
  if (ref.state.isRefreshing) return [];
  
  return await searchProducts();
});

Side Effects with ref.listen

class MyPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Run side effect when state changes
    ref.listen<int>(counterProvider, (previous, next) {
      if (next == 10) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('You reached 10!')),
        );
      }
    });
    
    return Text('${ref.watch(counterProvider)}');
  }
}

Refresh with ref.invalidate

// Re-run the provider
ElevatedButton(
  onPressed: () {
    ref.invalidate(userProvider);
  },
  child: Text('Refresh'),
)

Best Practices

1. Define providers globally

// ✅ At the top of the file
final counterProvider = StateProvider<int>((ref) => 0);

// ❌ Don't define inside widgets
class MyWidget extends ConsumerWidget {
  final counterProvider = StateProvider<int>((ref) => 0); // Wrong!
}

2. Create small, focused providers

// ✅ Correct - Each provider does one thing
final userProvider = FutureProvider<User>(...);
final userOrdersProvider = FutureProvider<List<Order>>(...);
final userBalanceProvider = Provider<double>(...);

// ❌ Wrong - Provider doing too much
final everythingProvider = FutureProvider<Everything>(...);

3. Use select to prevent unnecessary rebuilds

// ✅ Only rebuilds when name changes
final userName = ref.watch(userProvider.select((user) => user.name));

// ❌ Rebuilds when any field of User changes
final user = ref.watch(userProvider);

Summary

  • Riverpod: Advanced version of Provider
  • ProviderScope: Scope wrapping the application
  • StateProvider: For simple state
  • StateNotifierProvider: For complex state
  • FutureProvider: For async data
  • StreamProvider: For stream data
  • ref.watch: Use in build (listens)
  • ref.read: Use in events (doesn't listen)
  • family: Parameterized provider
  • autoDispose: Automatic cleanup

Riverpod is an excellent solution for state management in large and complex Flutter projects.

Comments