How setState, Provider, Riverpod and Bloc actually differ, when each one fits, and how to avoid the mistakes that make Flutter apps hard to change.
In this article
- 01What state management solves
- 02setState: the built-in starting point
- 03Provider: InheritedWidget made practical
- 04Riverpod: providers without BuildContext
- 05Bloc: explicit events and states
- 06Side-by-side comparison
- 07How to choose
- 08Handling loading and error states
- 09How each approach is tested
- 10Mistakes that hurt in any approach
- 11Further reading
What state management solves
In Flutter, the UI is a function of state: when state changes, widgets rebuild. The question is where that state lives and how widgets far apart in the tree read and change it. The Flutter documentation distinguishes ephemeral state, which belongs to one widget, such as the current tab or whether a field is focused, from app state, which many parts of the app share, such as the signed-in user or a shopping cart.
No single approach is best. The right choice depends on app size, team size and how much structure you want. This guide compares the four options most teams consider, using the same example: a shopping cart shown on a product page and a cart badge in the app bar.
setState: the built-in starting point
A StatefulWidget holds state in its State object and calls setState to change it, which schedules a rebuild of that widget and its children. It is simple, needs no packages and is perfect for ephemeral state like an expanded panel or a form field.
It breaks down for shared state. To show the cart count in the app bar and the cart contents on another screen, you would lift the state up to a common ancestor and pass it down through constructors, which quickly becomes tangled. That is the point to reach for one of the options below.
Provider: InheritedWidget made practical
Provider wraps Flutter's InheritedWidget so you can place an object above part of the tree and read it below. For the cart, you write a Cart class extending ChangeNotifier, with methods add and remove that call notifyListeners. A ChangeNotifierProvider near the root creates it.
Widgets call context.watch<Cart>() to rebuild on changes, context.read<Cart>() inside callbacks to call methods without listening, and Selector or context.select to rebuild only when one field, such as the item count, changes. Provider is easy to learn and still widely used, but lookups depend on BuildContext and a missing provider is only discovered at runtime.
Riverpod: providers without BuildContext
Riverpod comes from the same author as Provider and addresses its limitations. Providers are declared as top-level values, the app is wrapped in a ProviderScope and widgets read state through a ref, for example in a ConsumerWidget. Because providers do not depend on the widget tree, they can be read from anywhere, combined with each other and checked at compile time.
For the cart, a Notifier class holds the list of items and exposes methods to change it; Riverpod 3 is built around Notifier and AsyncNotifier and moves the older StateNotifier to a separate legacy import. ref.watch rebuilds on changes and ref.read calls methods. AsyncNotifier handles loading and error states for data fetched from an API, and providers can be overridden in tests, which makes Riverpod pleasant to test.
Bloc: explicit events and states
Bloc, via the flutter_bloc package, models logic as a stream of events in and states out. A CartBloc receives events such as ItemAdded and ItemRemoved and emits new CartState objects. Widgets use BlocBuilder to rebuild from state, BlocListener for one-off reactions such as showing a snackbar and BlocProvider to supply the bloc.
Cubit is a lighter variant where you call methods instead of dispatching events, which suits simpler features. Bloc's strength is predictability: every change has a named event, transitions can be logged and the bloc_test package makes tests concise. The cost is more code per feature.
Side-by-side comparison
The differences come down to ceremony, safety and structure:
- setState: no dependencies, ideal for local UI state, unsuitable for shared state
- Provider: low ceremony and widely understood, but tied to BuildContext with runtime errors for missing providers
- Riverpod: compile-time safety, easy composition and testing, a few new concepts to learn
- Bloc and Cubit: strict separation of UI and logic and traceable changes, but the most code per feature
How to choose
For a small app or prototype built by one or two developers, Provider or Riverpod keeps things moving. For a new app expected to grow, Riverpod is a strong default because it scales without much ceremony. For large teams, regulated domains or apps where auditing every state change matters, Bloc's explicit events pay for themselves.
Whatever you pick, keep setState for purely local UI state. Using a global solution for whether a dropdown is open adds complexity without benefit.
Handling loading and error states
Most state comes from the network, so model loading, data and error explicitly instead of juggling separate isLoading, error and data fields that can contradict each other. Riverpod's AsyncValue offers a when method with data, loading and error branches. With Bloc, define distinct states such as CartLoading, CartLoaded and CartError, or a single state with a status enum. With Provider, FutureProvider or a status field on the notifier does the same job. Whatever the approach, every screen should have a designed loading and error view.
How each approach is tested
Testability is often the deciding factor for larger apps. Logic inside setState can only be tested through widget tests with pumpWidget and WidgetTester. A Provider ChangeNotifier is a plain Dart class you can unit test directly. With Riverpod, create a ProviderContainer, override dependencies such as the API client with fakes and read the notifier. With Bloc, the blocTest helper describes a test as build, act and the expected list of emitted states, which reads almost like a specification.
Mistakes that hurt in any approach
Most state management pain comes from habits rather than the library:
- Putting business logic and API calls directly inside widgets
- Watching a whole object when only one field matters, causing needless rebuilds
- Mixing two or three approaches in one codebase without a rule for which goes where
- Mutating lists in place instead of creating new state objects
- Skipping tests for state classes, which are the easiest part of the app to test
Further reading
If you are still choosing a framework, our Flutter vs React Native comparison and Flutter explainer are good starting points. Nexzem builds cross-platform apps in Flutter, and our Flutter app development page describes how we structure larger codebases. If you need extra capacity, you can also hire Flutter developers for an existing app.


