I've been building a Flutter admin panel that handles push notifications, user filtering, audience estimation, and real-time Firestore data. At first I tried setState and Provider, but things got messy fast — especially when multiple screens needed to share and update the same state.
Switching to Riverpod with StateNotifier cleaned up everything. Here's how I structure it in production, not just todo app examples.
Why StateNotifier over other options?
I tried a few approaches before landing on StateNotifier:
- setState — works for simple screens but becomes a nightmare when you have filters on one screen affecting data on another
- Provider (vanilla) — better, but ChangeNotifier gets messy with multiple fields. You end up calling notifyListeners() everywhere and losing track of what changed
- Bloc — honestly felt like overkill for my use case. Too much boilerplate for what I needed
- Riverpod + StateNotifier — immutable state updates, no BuildContext dependency, easy to test. This clicked for me
Setting up a real example
Let me show you how I built a notification composer that has debounced audience estimation. When an admin selects filters (gender, city, tier, etc.), the app waits 500ms after they stop selecting, then calls a Cloud Function to estimate how many users match.
The state class
import 'package:freezed_annotation/freezed_annotation.dart';
part 'notification_state.freezed.dart';
@freezed
class NotificationState with _$NotificationState {
const factory NotificationState({
@Default('broadcast') String sendMode,
@Default('') String title,
@Default('') String body,
@Default(null) String? selectedGender,
@Default(null) String? selectedCity,
@Default(null) String? selectedTier,
@Default(false) bool excludeFakeUsers,
@Default(null) int? lastActiveDays,
@Default(false) bool isEstimating,
@Default(null) int? estimatedAudience,
@Default(false) bool isSending,
@Default(null) String? errorMessage,
}) = _NotificationState;
}
I use Freezed here because writing copyWith manually for 12+ fields is painful. One time I forgot to copy a field and spent 30 minutes debugging why my filter kept resetting. Never again.
The StateNotifier
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class NotificationNotifier extends StateNotifier<NotificationState> {
NotificationNotifier(this._ref) : super(const NotificationState());
final Ref _ref;
Timer? _debounceTimer;
void setSendMode(String mode) {
state = state.copyWith(
sendMode: mode,
selectedGender: null,
selectedCity: null,
selectedTier: null,
estimatedAudience: null,
);
}
void updateTitle(String title) {
state = state.copyWith(title: title);
}
void updateBody(String body) {
state = state.copyWith(body: body);
}
void setGenderFilter(String? gender) {
state = state.copyWith(selectedGender: gender);
_debouncedEstimate();
}
void setCityFilter(String? city) {
state = state.copyWith(selectedCity: city);
_debouncedEstimate();
}
void setTierFilter(String? tier) {
state = state.copyWith(selectedTier: tier);
_debouncedEstimate();
}
void setExcludeFakeUsers(bool exclude) {
state = state.copyWith(excludeFakeUsers: exclude);
_debouncedEstimate();
}
void _debouncedEstimate() {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_estimateAudience();
});
}
Future<void> _estimateAudience() async {
if (state.sendMode == 'broadcast') return;
state = state.copyWith(isEstimating: true, errorMessage: null);
try {
final filters = {
if (state.selectedGender != null) 'gender': state.selectedGender,
if (state.selectedCity != null) 'city': state.selectedCity,
if (state.selectedTier != null) 'tier': state.selectedTier,
'excludeFakeUsers': state.excludeFakeUsers,
if (state.lastActiveDays != null) 'lastActiveDays': state.lastActiveDays,
};
final result = await _ref.read(cloudFunctionsProvider).call(
'estimateAudience',
parameters: filters,
);
if (mounted) {
state = state.copyWith(
isEstimating: false,
estimatedAudience: result.data['count'] as int,
);
}
} catch (e) {
if (mounted) {
state = state.copyWith(
isEstimating: false,
errorMessage: 'Failed to estimate audience',
);
}
}
}
Future<void> sendNotification() async {
if (state.title.isEmpty || state.body.isEmpty) {
state = state.copyWith(errorMessage: 'Title and body are required');
return;
}
state = state.copyWith(isSending: true, errorMessage: null);
try {
await _ref.read(cloudFunctionsProvider).call(
'sendAdminNotification',
parameters: {
'title': state.title,
'body': state.body,
'sendMode': state.sendMode,
if (state.selectedGender != null) 'gender': state.selectedGender,
if (state.selectedCity != null) 'city': state.selectedCity,
if (state.selectedTier != null) 'tier': state.selectedTier,
'excludeFakeUsers': state.excludeFakeUsers,
},
);
state = const NotificationState();
} catch (e) {
state = state.copyWith(
isSending: false,
errorMessage: 'Failed to send notification: $e',
);
}
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
}
final notificationProvider =
StateNotifierProvider<NotificationNotifier, NotificationState>((ref) {
return NotificationNotifier(ref);
});
Using it in the UI
The nice thing about Riverpod is you can watch specific fields. Your widget only rebuilds when the field it cares about changes:
class AudienceEstimateWidget extends ConsumerWidget {
const AudienceEstimateWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isEstimating = ref.watch(
notificationProvider.select((s) => s.isEstimating),
);
final count = ref.watch(
notificationProvider.select((s) => s.estimatedAudience),
);
if (isEstimating) {
return const CircularProgressIndicator();
}
if (count != null) {
return Text(
'Estimated audience: $count users',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
);
}
return const Text('Select filters to see audience estimate');
}
}
The .select() is important — without it, this widget would rebuild every time ANY field in the state changes (like when the user types in the title field). With .select(), it only rebuilds when isEstimating or estimatedAudience changes.
Filter dropdowns
class GenderFilterDropdown extends ConsumerWidget {
const GenderFilterDropdown({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(
notificationProvider.select((s) => s.selectedGender),
);
return DropdownButtonFormField<String>(
value: selected,
decoration: const InputDecoration(labelText: 'Gender'),
items: const [
DropdownMenuItem(value: null, child: Text('All')),
DropdownMenuItem(value: 'male', child: Text('Male')),
DropdownMenuItem(value: 'female', child: Text('Female')),
DropdownMenuItem(value: 'other', child: Text('Other')),
],
onChanged: (value) {
ref.read(notificationProvider.notifier).setGenderFilter(value);
},
);
}
}
Things that tripped me up
- Checking mounted before updating state
If your Cloud Function takes 2-3 seconds and the user navigates away, calling state = state.copyWith(...) on a disposed notifier crashes the app. Always check mounted before updating state after async operations.
- Debounce timer not getting cancelled
Forgetting to cancel the timer in dispose() causes weird bugs — like the estimation running after you've already left the screen. Took me a while to figure out why I was getting random errors in the console.
- DropdownButtonFormField value vs initialValue
This is a Flutter thing, not Riverpod, but it got me. If you use value property, Flutter manages the display. If you switch to initialValue, it only sets it once on build. When your state updates the selected filter, you want value so the dropdown reflects the current state. I kept using initialValue and wondering why my dropdowns weren't updating.
- Don't put heavy logic in build()
I initially had the debounce logic inside the widget. Every rebuild created a new Timer. Moving it into the StateNotifier fixed it. Keep your widgets dumb — they should only read state and call notifier methods.
When NOT to use StateNotifier
For simple screens with 1-2 fields that don't share state with anything else — just use useState hook (with hooks_riverpod) or even plain setState. Not everything needs this pattern. I use StateNotifier when:
The state has 5+ fields
Multiple widgets need to read/write the same state
There's async logic (API calls, debouncing)
I want to test the logic without a widget
For a simple toggle or text input that lives in one widget? setState is fine. Don't over-engineer it.
Wrapping up
Riverpod + StateNotifier gives you a clean separation between your UI and business logic. The state is immutable (no accidental mutations), the logic is testable (no widget dependency), and the rebuilds are efficient (with .select()).
If you're building anything more complex than a single-screen app in Flutter, give this pattern a try. It might feel like extra boilerplate at first, but the moment you need to debug a state issue across multiple screens, you'll be glad everything is in one predictable place.
Top comments (0)