← All articles
Flutter9 min read

Flutter State Management: setState, Provider, BLoC and Riverpod compared

A practical guide to choosing state management in Flutter. What each option is good at, where each breaks down, and a decision checklist based on how your state behaves.

FlutterState ManagementBLoCProviderRiverpod

State management is the first architectural decision in any Flutter project, and the one teams argue about most. The debate usually happens at the wrong level: which package is best. A more useful question is what the state in your app actually does, because that determines which tool fits.

This guide covers the four common options, when each works, and where each breaks down.

First, describe the state

Three questions settle most decisions.

  1. What changes it? Only user interaction, or also sockets, timers, push messages, background downloads and hardware?
  2. How far does it travel? One widget, one screen, or the whole app?
  3. Does order matter? If two updates arrive together, can they interleave badly — a retry landing after a cancel, two submits in quick succession?

User-driven, locally scoped, order-insensitive state needs very little machinery. Externally driven, widely read, order-sensitive state needs structure.

setState

Built in, no packages. A StatefulWidget holds a value and calls setState to rebuild itself.

Good for:state that never leaves the widget — a toggled password field, an expanded card, an animation controller, a form field's local validation message.

Breaks down when: two widgets need the same value. The usual fix is lifting state up and passing callbacks down, which works for one level and becomes unreadable at three. If you find yourself threading a callback through widgets that do not care about it, that is the signal to move on.

Do not skip it out of habit. A surprising share of a real app is perfectly served by setState, and using it keeps the rest of the architecture smaller.

Provider

Provider is dependency injection plus change notification. An object extends ChangeNotifier, calls notifyListeners() when something changes, and widgets below it in the tree listen.

class CartModel extends ChangeNotifier {
  final _items = <Item>[];
  List<Item> get items => List.unmodifiable(_items);

  void add(Item item) {
    _items.add(item);
    notifyListeners();
  }
}

Good for: a cart, a theme, a logged-in user, filter settings, a form spanning several widgets. Anything the user drives that several widgets read.

Breaks down when:

  • notifyListeners() is all-or-nothing. Every listener rebuilds, even for a change it does not care about. Selector and splitting into smaller providers help, but this needs discipline.
  • Logic accumulates in the notifier until it becomes a second app made of methods.
  • There is no history. You can inspect the current state, not the sequence that produced it, which makes intermittent bugs hard to reconstruct.

BLoC

BLoC models change as explicit events that produce states. The UI dispatches an event, the bloc emits a new state, the UI renders it.

sealed class CheckoutState {}
class CheckoutIdle       extends CheckoutState {}
class CheckoutValidating extends CheckoutState {}
class CheckoutAwaitingGateway extends CheckoutState {}
class CheckoutSucceeded  extends CheckoutState { final String orderId; ... }
class CheckoutFailed     extends CheckoutState { final String reason; ... }

That extra indirection is the cost. What you buy is worth it in specific situations:

  • Events arrive from outside the UI. Sockets, push notifications, connectivity changes, Bluetooth. The UI did not ask for them, so modelling them as events is honest.
  • A flow has genuinely distinct states. Checkout is not a boolean. Sealed classes make impossible combinations unrepresentable — you cannot be both loading and failed.
  • Order matters. Event sequencing is explicit rather than implied by whichever callback fired first.
  • You want tests without widgets. Feed events, assert the emitted states.

Breaks down when: applied to everything. A settings toggle does not need an event class, a state class and a bloc. Teams that mandate BLoC everywhere end up with more files than features.

Riverpod

Riverpod grew out of Provider's limitations. State is declared in providers that live outside the widget tree, so it is not tied to BuildContext, dependencies are resolved at compile time, and async values come with loading and error states built in.

Good for:new projects that want finer-grained rebuilds than Provider without BLoC's ceremony, and apps with a lot of async data where AsyncValue removes repetitive loading and error plumbing.

Consider carefully when: the team already knows Provider or BLoC well, or the codebase is large and half-migrated. Two competing patterns in one codebase costs more than either pattern alone.

A decision table

SituationReasonable choice
Value used only inside one widgetsetState
User-driven value shared by a few screensProvider or Riverpod
Lots of async data, loading and error everywhereRiverpod
Events arrive from sockets, push or hardwareBLoC
Multi-step flow with distinct states (checkout, onboarding)BLoC
Needs testing without building widgetsBLoC or Riverpod

Mixing is normal and usually correct. A settings screen on setState and a live order screen on BLoC is a healthy codebase, not an inconsistent one.

Mistakes that cause most of the pain

One global store for everything

It feels tidy and ends badly: every change wakes every listener, and no one can tell which screen owns which field. Scope state to the smallest subtree that needs it.

Rebuilding whole pages

Wrapping an entire page in one consumer rebuilds the page on every change. Wrap the widget that displays the value instead. This single habit prevents most avoidable jank.

Using API models as UI state

If widgets read the decoded JSON directly, every backend change reaches into the UI. Map responses into your own models at the boundary.

Judging the choice on a counter app

Every option looks elegant on a counter. Evaluate against the hardest screen you expect to build — the one with several async sources, a socket and a payment step.

Checklist before you commit

  • Can you name every state a screen can be in? If not, model them explicitly.
  • Does anything other than the user change this state?
  • Is the rebuild scope the smallest widget that shows the value?
  • Can the logic be tested without pumping a widget?
  • Would a new developer find where a given piece of state lives within a minute?

There is no wrong package here. The failure mode is choosing before deciding what the state is.

Read next