Skip to main content
  1. Notes/

Blog · Guide

Flutter State Management: What I'd Actually Pick in 2026

··8 mins·
Mjashem
Guide Flutter State-Management Riverpod Dart
Flutter State Management: What I'd Actually Pick in 2026
Mohammad Jashem
Author
Mohammad Jashem
Senior full-stack mobile engineer with 7+ years building production apps end-to-end for Android, iOS, Web, and TV. Flutter, React Native, Expo — plus backend, CI/CD, and infrastructure. Architecture-first, AI-native delivery. Available for freelance and Upwork engagements.
Table of Contents

I’ve shipped three production Flutter apps — the NCC App (seven years and counting), Kahf Kids, and Crewlix — and every one of them runs on Riverpod. Not because Riverpod is objectively best. Because after a decade of setState sprawl, Provider, Bloc, and Riverpod across real codebases, it’s the one that stopped costing me time.

Most “Flutter state management” guides are a counter-app tour of four libraries. That’s not what you need if you’re shipping a real app. This is what I’d actually tell a team starting a Flutter product today — the decision that matters, the library I’d pick, the modern API (most tutorials still show the deprecated one), and the part nobody writes about that actually determines whether your app is fast.

The decision isn’t which library. It’s compile-time vs runtime.
#

Pick any two state-management libraries and the real difference collapses to one question: do you want the type system to catch your state bugs at compile time, or do you want the flexibility of resolving state at runtime?

  • setState is runtime, local, and invisible to the type system outside the widget.
  • Provider is runtime resolution through InheritedWidgetProvider.of<T>() fails at runtime if T isn’t in the tree.
  • Bloc is compile-time-ish (events and states are typed), but the wiring is runtime and the ceremony is high.
  • Riverpod is compile-time resolution. A missing provider is a compile error. A provider used after dispose is a compile error. The graph is declared, not discovered.

If you’ve ever shipped a release and had a ProviderNotFoundException surface on a user’s device in a code path your tests didn’t hit, you understand the appeal of moving that failure left into the compiler. That single property is why every app I maintain is on Riverpod. The rest is taste.

The libraries, honestly
#

setState — still correct for local widget state
#

setState is not a beginner’s tool you outgrow. It is the right tool for state that lives and dies inside one widget — animation controllers, a text field’s focus, a single toggle. Reaching for a global store to hold a widget-local value is a more common mistake than using setState for too long.

class _FilterChipState extends State<FilterChip> {
  bool _selected = false;

  void _toggle() => setState(() => _selected = !_selected);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _toggle,
      child: Chip(label: Text(widget.label), selected: _selected),
    );
  }
}

If a piece of state is read by one widget and rewritten by that same widget, setState is finished. Stop.

Provider — fine, but I wouldn’t start a 2026 app on it
#

Provider is InheritedWidget with ergonomics. It’s a perfectly good choice for a small app, and the NCC App started on Provider in 2019. The reason I wouldn’t start fresh on it today is the runtime resolution: context.read<T>() and context.watch<T>() depend on T being injected somewhere up the tree, and the compiler cannot verify that for you. On a 70-module app that runtime lookup becomes a real source of “works in dev, crashes in the field.”

If you’re already on Provider and it’s working, there’s no urgent reason to migrate. If you’re starting, start on Riverpod.

Bloc — powerful, ceremony-heavy, team-shaped
#

Bloc is the right call for a specific shape of team and product: large teams that want an auditable event trail, explicit state transitions, and a structure that survives engineer turnover. The cost is ceremony — every state change is an event class, a state class, and a handler. For a counter that’s absurd. For a payment flow touched by five engineers across three time zones, that ceremony is the documentation.

I don’t use Bloc on the apps I own because they’re small-team and product-velocity-shaped. If I were building a regulated fintech app with a large rotating team, Bloc’s discipline would earn its keep.

Riverpod — what I’d pick
#

Riverpod gives you compile-time safety, no BuildContext dependency (providers are usable in pure Dart — useful for tests, view models, and anything outside the widget tree), first-class async (FutureProvider, StreamProvider), and autoDispose for cache you don’t have to think about. The NCC App migrated Provider → Riverpod in year two and that provider layer has been stable through null safety, Material 3, the Impeller backend, and Dart 3. It’s the part of the codebase I touch least.

The modern Riverpod API (most tutorials show the old one)
#

Here’s the trap. Search “Riverpod tutorial” and most results still teach StateNotifier and StateNotifierProvider. Both are deprecated in Riverpod 2.x. If you’re writing a new app, use Notifier and NotifierProvider.

The old (deprecated) way
#

// Deprecated in Riverpod 2.x — don't write this in a new app.
class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);
  void increment() => state++;
}

final counterProvider = StateNotifierProvider<CounterNotifier, int>(
  (ref) => CounterNotifier(),
);

The modern way
#

class Counter extends Notifier<int> {
  @override
  int build() => 0;

  void increment() => state++;
}

final counterProvider = NotifierProvider<Counter, int>(Counter.new);

Same ergonomics, no deprecation, and it composes with code generation. With Riverpod’s codegen (what the NCC App actually uses), it’s even cleaner:

@riverpod
class Counter extends _$Counter {
  @override
  int build() => 0;
  void increment() => state++;
}

The pattern I use across all three production apps is a Notifier driving a sealed (Freezed) state union. Every async feature’s state is one of four cases, and the compiler enforces that the UI handles all of them:

@freezed
class FeedState with _$FeedState {
  const factory FeedState.initial() = _Initial;
  const factory FeedState.loadInProgress() = _LoadInProgress;
  const factory FeedState.loadSuccess({required List<Item> items}) = _LoadSuccess;
  const factory FeedState.loadFailure({required String message}) = _LoadFailure;
}

class Feed extends Notifier<FeedState> {
  @override
  FeedState build() {
    _load();
    return const FeedState.initial();
  }

  Future<void> _load() async {
    state = const FeedState.loadInProgress();
    try {
      final items = await ref.read(feedRepositoryProvider).fetch();
      state = FeedState.loadSuccess(items: items);
    } catch (e) {
      state = FeedState.loadFailure(message: e.toString());
    }
  }
}

The UI does a switch over the state and the compiler tells you if a case is missing. That exhaustiveness is worth more than any library comparison.

The part that actually matters: where state lives, not which library holds it
#

Here’s the thing seven years taught me. The choice between Riverpod, Bloc, and Provider matters far less than where your state lives. The NCC App survived seven years of Flutter churn — null safety, Material 3, Impeller, Dart 3 records and patterns — without a rewrite, and the reason isn’t Riverpod. It’s that the state, the business rules, and the entities live in a layer that doesn’t know Flutter exists.

If your domain logic is reachable only through a Riverpod provider that lives in a presentation layer, then a Material 3 migration is a UI task and a Dart 3 migration is a syntax pass. If your domain logic is tangled into your widgets and your state holders, every framework bump is a rewrite. I wrote that whole story up in maintaining a Flutter app for 7 years.

State management libraries are the plumbing. The architecture — the seam between your domain and the framework — is the load-bearing wall. Pick Riverpod, pick Bloc, either is fine. Get that seam right, or no library will save you.

Rebuild discipline: the performance lever nobody mentions
#

The second thing that determines whether your app feels fast is rebuild scope, and this is where teams misuse Riverpod. Two rules:

Watch the smallest possible slice. ref.watch(provider) rebuilds the whole widget when the provider changes. If a provider holds an object with five fields and your widget only reads one, use select to rebuild only when that one field changes:

// Rebuilds on any user change — wasteful.
final user = ref.watch(userProvider);
return Text(user.name);

// Rebuilds only when name changes.
final name = ref.watch(userProvider.select((u) => u.name));
return Text(name);

Never watch inside event handlers. ref.watch is for build. Inside onTap, onPressed, callbacks — use ref.read. watch in a handler registers a listener that outlives the intent and causes rebuilds you didn’t ask for:

// Wrong — watch in a handler.
onPressed: () => ref.watch(cartProvider).addItem(),

// Right — read in a handler.
onPressed: () => ref.read(cartProvider.notifier).addItem(),

These two disciplines, on a 70-module app, are the difference between a list that scrolls at 60fps and one that stutters every time a background timer ticks. The library doesn’t fix this for you. The rebuild graph does.

Decision matrix, for real
#

SituationWhat I’d pickWhy
State used by one widget onlysetStateNo reason to leave the widget
Small app, solo dev, shipping fastRiverpodCompile-time safety, low ceremony
70-module app, long horizonRiverpod + Freezed + codegenExhaustive states, survives churn
Large team, regulated domainBlocEvent trail, enforced structure
You’re already on Provider, it worksStayMigration cost > benefit

The “start on Provider, graduate to Riverpod or Bloc” advice in older guides made sense in 2020. In 2026, with Riverpod’s codegen and compile-time graph, I’d start there and skip the graduation.

What I’d tell a team today
#

Pick Riverpod. Use the modern Notifier API, not the deprecated StateNotifier. Drive your async state through sealed unions so the compiler enforces the loading/error/success cases. Spend more time on where your state lives — behind a domain seam — than on which library holds it. And get rebuild discipline right early: select for granular reads, read in handlers, watch only in build.

The library is a small decision. The seam and the rebuild graph are the big ones. Get those right and Flutter state management stops being a problem you think about — which, after seven years, is exactly the goal.


If you’re scoping a Flutter app and want the architecture right from day one — the state layer, the domain seam, the rebuild discipline — that’s the work I do. Get in touch, or read the guide to hiring a Flutter developer in 2026.