Flutter · State Management
Why We Use Riverpod for State Management in Flutter Apps
Every Flutter project has to answer the state management question. After shipping production apps with several approaches, Riverpod became our default — here's the reasoning, including where it isn't the right answer.
The problem state management actually solves
State management debates get religious quickly, so it helps to state the actual engineering problem: application state must be readable from anywhere in the widget tree, changeable in a predictable way, and testable without booting a UI. Every solution — Provider, Bloc, GetX, Riverpod, plain setState — is a different trade-off between those three needs and the ceremony required to satisfy them.
In client work the choice matters twice: once for us while building, and once for whoever maintains the app after us. A clever but obscure setup is a liability the moment the codebase changes hands. That pushes us toward solutions that are explicit, widely documented, and hard to misuse.
What Riverpod gets right
Riverpod is Provider rethought by its own author, with the main structural flaw removed: providers no longer live inside the widget tree. That single change has practical consequences that show up daily in real projects:
- —Compile-time safety — reading a provider that doesn't exist is a compiler error, not a runtime crash in a screen QA didn't test
- —No BuildContext coupling — business logic can read state without a widget, which makes it callable from anywhere and trivially unit-testable
- —Granular rebuilds — widgets subscribe to exactly the state they use, so a change in one field doesn't repaint a whole screen
- —Explicit dependencies — a provider declares what it depends on; the object graph is visible in code instead of assembled by magic
- —First-class async — FutureProvider and StreamProvider make loading/error/data states a type, not a boolean flag convention
Why we skip the code generation
Riverpod offers an annotation-based code generation layer. We deliberately use manual providers without it. Code generation adds a build_runner step to every change, generated files to every diff, and a second syntax to learn — and what it buys (slightly terser declarations) doesn't cover what it costs in day-to-day friction and onboarding time.
Manual providers keep the mental model simple: what you read is what runs. In our production Flutter apps — Invoice Guru among them — the full state layer is plain, hand-written providers, and that decision has aged well through every Flutter and Riverpod upgrade.
Where we'd choose differently
No default survives every context. When we take over a codebase with an established Bloc architecture, we extend it in Bloc — architectural consistency beats personal preference in a living product. For a trivial utility app with two screens, plain setState with a couple of ValueNotifiers is honest and sufficient; installing a state framework there is ceremony without benefit.
The point is not that Riverpod wins a beauty contest. It's that a team should have one well-understood default and a short list of reasons to deviate — that's what keeps a portfolio of client projects maintainable.
Takeaways
- ✓Choose state management for readability, predictability, and testability — not for novelty.
- ✓Riverpod's compile-time safety and context-free reads eliminate a whole class of production bugs.
- ✓Skipping code generation keeps diffs clean and onboarding fast; terser syntax isn't worth a build pipeline.
- ✓Consistency beats preference: extend the architecture a codebase already has.
- ✓Have a default, know its limits, and write down when you deviate.
Building something where this matters?
This is how we work on every project — mobile, web, and AI. If you want a team that reasons this way about your product, let's talk.
Get a free estimate