I Rewrote My Flutter App 3 Times — Here's What I Learned About State Management
I've rewritten the state management layer of my Flutter apps three separate times. Not because I was chasing the shiny new thing. Because the thing I was using actually broke.
Iconis, Prod Me, and Nexto all started with Provider. And all three ended up with a week-long detour where I ripped it out and replaced it with something else. The last time hurt enough that I sat down and figured out what I actually needed — not what Twitter said was cool.
Here's what I learned.
The Three Stages of Flutter State Management Grief
Stage 1: Provider (The Default)
When I built the first version of Iconis (an app icon studio), I used Provider because that's what the Flutter docs recommended. It worked fine for the first few weeks. Then I added authentication. Then cloud sync. Then a real-time preview that needed to react to 12 different streams simultaneously.
Suddenly my widget tree looked like this:
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => IconProvider()),
ChangeNotifierProvider(create: (_) => SyncProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (_) => ExportProvider()),
ChangeNotifierProxyProvider<AuthProvider, SyncProvider>(...),
// it just kept going
],
child: const MyApp(),
)
The real problem wasn't the nesting. It was that every time SyncProvider changed, three unrelated widgets rebuilt. I spent more time wrapping things in Consumer and Selector than I did building actual features.
Stage 2: BLoC (The Over-Engineer)
For Prod Me (my persistent reminder app), I thought I'd learned my lesson. I went all-in on BLoC. Separate Event classes, State classes, Bloc classes. It was clean. It was testable. It also required 47 files to show a simple reminder list.
Here's what shipping a tiny feature looked like:
// 1. Define event
abstract class ReminderEvent {}
class AddReminderEvent extends ReminderEvent {
final Reminder reminder;
AddReminderEvent(this.reminder);
}
// 2. Define state
class ReminderState {
final List<Reminder> reminders;
final bool isLoading;
ReminderState({this.reminders = const [], this.isLoading = false});
}
// 3. Create bloc
class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
ReminderBloc() : super(ReminderState()) {
on<AddReminderEvent>((event, emit) {
emit(state.copyWith(reminders: [...state.reminders, event.reminder]));
});
}
}
// 4. Provide bloc
BlocProvider(create: (_) => ReminderBloc())
// 5. Use in widget
BlocBuilder<ReminderBloc, ReminderState>(
builder: (context, state) {
return ListView(...);
},
)
Five files for adding an item to a list. The architecture was clean — I'll give it that. But I was spending 60% of my time writing boilerplate and 30% debugging "I forgot to add the event to the right Bloc" errors. Something had to give.
Stage 3: setState (The Overcorrection)
For Nexto (my todo app), I went the opposite direction. No state management library at all. Just good old setState with a service layer. And you know what? It worked shockingly well for the first 500 lines.
Then I added drag-and-drop reordering. And filtering. And search. And suddenly I was passing callback functions through five layers of widgets and praying I hadn't created a memory leak.
// My "service layer" started looking like this:
class TodoService {
final List<VoidCallback> _listeners = [];
void addListener(VoidCallback cb) => _listeners.add(cb);
void removeListener(VoidCallback cb) => _listeners.remove(cb);
void _notify() => _listeners.forEach((cb) => cb());
// I basically rebuilt ChangeNotifier by hand
// and did a worse job of it
}
That's when I finally admitted I was the problem. I was ping-ponging between architecture extremes instead of finding the middle ground.
What I Actually Needed
After three apps of painful rewrites, I made a list of what I actually wanted from a state management solution:
- No boilerplate — I don't want to write 5 files to show a list of items
- Fine-grained rebuilds — changing one field shouldn't rebuild the whole page
- Testable without ceremony — I should be able to test logic without a widget test harness
- Works with async — API calls, local DB queries, file operations — the common stuff
- Not a code generator — I don't want to run a command every time I add a feature
That's when I tried Riverpod. And honestly? I should have started here.
How I Structure Flutter Apps Now
Here's the pattern I've settled on after shipping three apps with Riverpod. It's not revolutionary, but it's practical and it works.
The One-File Provider Pattern
Instead of spreading logic across events, states, blocs, and providers, I keep related logic in one file:
// providers/reminder_provider.dart
final reminderRepositoryProvider = Provider<ReminderRepository>((ref) {
return ReminderRepository();
});
final remindersProvider = AsyncNotifierProvider<RemindersNotifier, List<Reminder>>(() {
return RemindersNotifier();
});
class RemindersNotifier extends AsyncNotifier<List<Reminder>> {
@override
Future<List<Reminder>> build() async {
final repo = ref.read(reminderRepositoryProvider);
return repo.fetchAll();
}
Future<void> addReminder(Reminder reminder) async {
final repo = ref.read(reminderRepositoryProvider);
await repo.save(reminder);
ref.invalidateSelf(); // re-fetch the list
}
Future<void> toggleComplete(String id) async {
// Optimistic update
state = AsyncData(state.value!.map((r) {
return r.id == id ? r.copyWith(isCompleted: !r.isCompleted) : r;
}).toList());
final repo = ref.read(reminderRepositoryProvider);
await repo.toggle(id);
}
}
That's it. One file. One provider for the repository, one notifier for the state. build() fetches initial data, methods update it. ref.invalidateSelf() tells Riverpod "this data is stale, re-fetch it."
Where I Keep State
I split my providers into three categories:
- Data providers — API clients, database instances, shared preferences. Created once, injected everywhere.
- State notifiers — AsyncNotifier or Notifier classes that hold mutable UI state. One per feature screen.
- Derived providers — Computed values. Filtered lists, calculated totals, combined data from multiple sources.
// Derived provider example: filtered reminders
final filteredRemindersProvider = Provider<List<Reminder>>((ref) {
final reminders = ref.watch(remindersProvider);
final filter = ref.watch(reminderFilterProvider);
if (filter == 'active') {
return reminders.value!.where((r) => !r.isCompleted).toList();
}
if (filter == 'completed') {
return reminders.value!.where((r) => r.isCompleted).toList();
}
return reminders.value ?? [];
});
Derived providers auto-recompute when their dependencies change. No manual notification. No addListener / removeListener. It just works.
Testing Without Pain
This is where Riverpod really shines. Testing a notifier is dead simple:
void main() {
test('adds a reminder to the list', () async {
final container = ProviderContainer();
final notifier = container.read(remindersProvider.notifier);
await notifier.addReminder(
Reminder(title: 'Test reminder', priority: Priority.high),
);
final state = container.read(remindersProvider);
expect(state.value!.length, 1);
expect(state.value!.first.title, 'Test reminder');
});
}
No WidgetTester. No mocking BuildContext. Just a ProviderContainer and a test. I can run these in milliseconds instead of seconds.
When You Shouldn't Use Riverpod
I'm not here to sell you on Riverpod. It's not the right choice for every project:
- Prototypes — If you're building something that'll live for a weekend, use setState. Don't overthink it.
- Simple apps — A flashlight app doesn't need Riverpod. A counter app doesn't need Riverpod. Use what's simple.
- Your team already knows BLoC — The best state management is the one your team can ship with. Don't rewrite for the sake of rewriting (I say this as someone who did exactly that three times).
The Bottom Line
After rewriting state management in three production Flutter apps, I landed on Riverpod because it's the least opinionated about everything except what matters: your data flows one way, rebuilds are fine-grained, and testing is trivial.
Is it perfect? No. The autodispose behavior took me a week to wrap my head around. And the modifier syntax (.family, .autoDispose) can get confusing. But for 90% of what I build, it's the right tool.
If you're just starting a Flutter project and wondering what to reach for, skip Provider and try Riverpod. You'll save yourself a rewrite.
I built Iconis, Prod Me, and Nexto with Flutter. If you're working on a Flutter project too, check out Iconis for app icon design and Snippet Ark for keeping your code snippets organized across projects. What's your Flutter state management horror story? I'd love to hear it.