← Back to blog
Flutter State Management · Part 6 of 7
September 18, 20269 min read

Which State Management? setState vs Provider vs Riverpod vs Bloc

FlutterDartState Management

Which State Management Should You Use?

This is Part 6 — the final content part of the Flutter State Management series (the 100-question bank is next). You've met every major tool: setState, InheritedWidget, Provider, Riverpod, and Bloc/Cubit. Now the question every developer actually asks: for this feature, which one do I use?

The honest answer the internet rarely gives: there's no single winner, and the best apps mix tools. What you need is judgment — a way to look at a piece of state and know which tool fits. That's what this part builds.

Assumes all prior parts. Flutter 3.38 / Dart 3.12. Versions: provider 6.x, Riverpod 3.0, flutter_bloc 9.x.


The one question that decides 80% of cases

Before comparing frameworks, answer this:

Is this state LOCAL to one widget, or SHARED across widgets?

That single split resolves most decisions:

  • Local & ephemeral — a toggle, a focus, an animation flag, a PageController. Owned by one widget, nobody else cares. → setState. Full stop. Don't reach for a framework. (Part 1)
  • Shared — read or written by widgets that are far apart (cart, auth, theme, fetched data). → you need a state management solution (Provider / Riverpod / Bloc).

The most common mistake in both directions: wrapping a humble checkbox in a Bloc (over-engineering), or prop-drilling app-wide auth state through ten widgets because "setState is simpler" (under-engineering). Match the tool to the scope of the state.

Only once you've decided the state is shared do the frameworks compete.


The four tools at a glance

| | setState | Provider | Riverpod | Bloc/Cubit | | --- | --- | --- | --- | --- | | Scope | One widget | Shared | Shared | Shared | | Built on | State | InheritedWidget | top-level objects | streams + InheritedWidget | | Boilerplate | Minimal | Low | Low–medium | High | | Compile-time safety | n/a | ❌ runtime | ✅ | ✅ (typed events/states) | | Needs BuildContext | n/a | ✅ | ❌ (ref) | ✅ to access | | Async ergonomics | manual | manual | AsyncValue | sealed states | | Structure imposed | none | little | little | strict | | Best for | local UI | small/medium apps, existing code | new apps, any size | large teams, complex flows | | Learning curve | trivial | easy | moderate | steeper |

Read the table as a gradient from least structure/least power (setState) to most structure/most ceremony (Bloc). More structure pays off as apps and teams grow; it's dead weight on a weekend project.


A decision tree you can actually follow

Is the state used by only ONE widget (and its direct children)?
│
├─ YES ──────────────────────────────────► setState   (Part 1)
│
└─ NO (it's shared) →
     │
     Is it just a value/service to inject (no reactive UI)?
     ├─ YES ─────────────────────────────► Provider<T> / Riverpod Provider (DI)
     │
     Starting a NEW project / want compile-time safety + no context?
     ├─ YES ─────────────────────────────► Riverpod   (Part 4)
     │
     Big team, complex flows, want an event audit trail & one rigid way?
     ├─ YES ─────────────────────────────► Bloc        (Part 5)
     │
     Maintaining an existing Provider codebase?
     └─ YES ─────────────────────────────► Provider    (Part 3)

It's not gospel — it's a starting bias. Two equally good teams can pick Riverpod and Bloc for the same app and both succeed.


Scenarios, mapped

Concrete beats abstract. Here's the tool you'd reasonably pick for each, and why:

| Scenario | Pick | Why | | --- | --- | --- | | Password field show/hide toggle | setState | Purely local; a framework would be over-engineering. | | Expand/collapse a card | setState | Local UI state, nobody else reads it. | | A shared ApiClient/repository (no UI reactivity) | Provider / Riverpod Provider | Dependency injection of a service. | | App theme (light/dark) read app-wide | Riverpod / Provider | Shared, reactive; a notifier + watch. (See the Riverpod theming series.) | | Auth/session across the whole app | Riverpod (new) / Bloc (big team) | Shared, long-lived, compile-safety matters. | | Fetched list with loading/error states | Riverpod AsyncValue | Built-in async/loading/error handling. (AsyncValue) | | Multi-step checkout needing an action audit | Bloc | Event log of each step aids debugging. | | Migrating a legacy Provider app | Provider (stay) | Don't rewrite working code without reason. |


"Can I mix them?" — yes, and you should

A healthy real-world app is not "100% Bloc" or "100% Riverpod." It's:

  • setState for the dozens of tiny local toggles and animations.
  • One shared solution (Riverpod or Bloc or Provider) for app-wide state.
  • Maybe a Cubit for one gnarly feature even in a Riverpod app.
// Totally normal: setState for local UI INSIDE a Riverpod app.
class _ExpandableState extends State<Expandable> {
  bool _open = false; // local — setState is correct here
  @override
  Widget build(BuildContext context) => Column(children: [
    ListTile(onTap: () => setState(() => _open = !_open)),
    if (_open) const Details(), // Details might read Riverpod providers
  ]);
}

Don't pick one tool to rule everything. Pick a primary shared-state solution for consistency, and still use setState freely for local UI. Forcing every checkbox through your global store is a real anti-pattern.


Performance footnote (ties to the internals series)

Whatever you choose, the efficiency rules from the Dart Internals series still apply: subscribe to the narrowest slice of state a widget needs (Provider's select, Riverpod's select, Bloc's buildWhen), keep static subtrees const, and don't do heavy work in build(). The framework moves where state lives; it doesn't exempt you from rebuilding only what changed.

A poorly-scoped Riverpod app can rebuild more than a well-scoped setState one. The tool helps; discipline delivers the performance.


Practice Challenges

Challenge 1 — Local or shared? Classify and pick a tool: (a) a checkbox in a settings row, (b) the list of items in a shopping cart, (c) the scroll offset only this list reads.

Show solution

(a) Local → setState. (b) Shared (app bar badge, cart page, checkout) → Riverpod/Provider/Bloc. (c) Local → setState (or a controller). Scope decides.

Challenge 2 — Spot the over-engineering. A junior wraps a single expand/collapse animation in a full Bloc with Expand/Collapse events. Critique it.

Show solution

It's over-engineering: the state is local to one widget and trivial, so a Bloc's events/states/handlers are pure ceremony with no payoff. setState(() => _open = !_open) is correct, shorter, and clearer.

Challenge 3 — Spot the under-engineering. Auth status is setState-held at the root and threaded through 12 widgets to reach the profile screen. What's wrong and what fixes it?

Show solution

It's under-engineering — prop-drilling shared app-wide state, which is exactly Part 1's pain. Move auth into a shared solution (Riverpod/Provider/Bloc) so any screen reads it directly via ref.watch/context.watch without threading.

Challenge 4 — New app, async-heavy. You're starting a new app that's mostly fetching/displaying server data with loading and error states. Which tool, and which feature specifically?

Show solution

Riverpod, leaning on AsyncValue (with FutureProvider/AsyncNotifier) so loading/data/error are handled exhaustively, plus compile-time safety. See AsyncValue.

Challenge 5 — Justify Bloc. Give a scenario where Bloc's extra boilerplate is genuinely worth it over Riverpod.

Show solution

A large app with many developers and a complex, auditable flow (e.g. a regulated payment/checkout pipeline) where you want one prescribed pattern and a named event log of every user action for debugging and replay. The structure and traceability outweigh the ceremony there.


Questions to test yourself

Q1 (basic). What's the first question to ask when choosing a state solution?

Show answer

Is the state local to one widget or shared across widgets? Local → setState; shared → a state-management solution. Scope resolves most decisions.

Q2 (basic). When is setState the right choice, not a fallback?

Show answer

For local, ephemeral UI state owned by one widget — toggles, focus, animation flags, expand/collapse. Using a framework there is over-engineering.

Q3 (intermediate). Give two reasons to pick Riverpod over Provider for a new app.

Show answer

Compile-time safety (no runtime ProviderNotFoundException) and no BuildContext dependence (read via ref, usable outside widgets) — plus niceties like AsyncValue. See Part 4.

Q4 (intermediate). When does Bloc's extra structure pay off, and when is it a liability?

Show answer

It pays off in large apps/teams with complex flows needing predictability and an event audit trail. It's a liability for small apps or simple local state, where the events/states/handlers are needless boilerplate.

Q5 (advanced). Why is "use one tool for everything" an anti-pattern?

Show answer

Different state has different scope: forcing local UI state (a checkbox) through a global store adds ceremony and rebuild surface for no benefit, while it's fine to keep app-wide state in a shared solution. Healthy apps pick one primary shared-state solution for consistency but still use setState for local UI. Matching the tool to the state's scope keeps code simple and performant.

Q6 (advanced). How does framework choice interact with rebuild performance?

Show answer

The framework decides where state lives, not how little rebuilds. You still must subscribe to the narrowest slice (select / buildWhen), keep static subtrees const, and avoid heavy work in build() (efficiency rules). A poorly-scoped Riverpod/Bloc app can rebuild more than a well-scoped setState one — discipline delivers performance, not the tool.


Wrapping up

  • Scope first: local/ephemeral → setState; shared → a state-management solution.
  • Read the tools as a structure gradient: setState → Provider → Riverpod → Bloc (more power and ceremony as you go).
  • Provider for existing codebases; Riverpod as the strong default for new apps (compile-safe, no context, AsyncValue); Bloc for large teams and complex, auditable flows.
  • Mix tools deliberately — one primary shared solution plus setState for local UI; don't force everything through a global store.
  • Framework choice doesn't exempt you from rebuilding only what changed — scope subscriptions, use const, keep build() light.

That's the judgment. Part 7 is the proving ground: a 100-question mastery bank — hints and solutions — plus 10 coding mini-exercises and a capstone that builds a small app touching every tool in the series.