State Management: Riverpod & Bloc
Provider is a great default, but it isn't the end of the road. This lesson covers two more approaches you'll see constantly in real Flutter codebases and job listings: Riverpod, which takes Provider's ideas and refines them, and Bloc, a more rigidly structured pattern favored on larger teams. Neither replaces what you just learned — they're different points on the same spectrum of "how do I share state across my widget tree cleanly."
Riverpod: Provider's Spiritual Successor
Riverpod was created by Remi Rousselet — the same original author of the Provider package — specifically to fix a handful of rough edges Provider couldn't solve without a rewrite. The name is even an anagram of "Provider." Two improvements stand out:
| Improvement | Why it matters |
|---|---|
No BuildContext required | Provider's context.watch/context.read need a BuildContext, which means you can only read state from inside widgets. Riverpod providers can be read from plain Dart code too (services, use cases, tests) — no widget tree involved. |
| Compile-time safety | With Provider, asking for a type that was never registered (or asking above the wrong context) is a runtime crash you only discover by running the app. Riverpod catches many of these mistakes at compile time instead, since providers are plain top-level variables the analyzer can check. |
The Core Building Block: A Top-Level Provider
Instead of wrapping a widget in the tree like ChangeNotifierProvider, a Riverpod
provider is declared as a top-level, global variable — outside of any class or
widget. Your whole app is then wrapped once in a ProviderScope, and any widget can read
any provider by name, regardless of where in the file it was declared.
// Declared at the top level of a file — not nested inside a widget
final counterProvider = StateProvider<int>((ref) => 0);
void main() {
runApp(
// ProviderScope replaces ChangeNotifierProvider at the app root
ProviderScope(child: MyApp()),
);
}
class CounterScreen extends ConsumerWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref.watch() subscribes this widget to counterProvider changes
final count = ref.watch(counterProvider);
return Scaffold(
body: Center(child: Text('Count: $count')),
floatingActionButton: FloatingActionButton(
// ref.read() to trigger a one-off update, same idea as context.read
onPressed: () => ref.read(counterProvider.notifier).state++,
child: const Icon(Icons.add),
),
);
}
}
Notice how closely this mirrors Provider's own vocabulary — ref.watch rebuilds when the
value changes, ref.read grabs it once without subscribing, exactly like
context.watch and context.read from the previous lesson. For more complex
shared state than a single counter, StateNotifierProvider pairs a provider with a
StateNotifier class (conceptually similar to the ChangeNotifier you saw
with CartModel), giving you a proper place to put update logic and methods.
Bloc: Events In, States Out
Bloc (Business Logic Component) takes a fundamentally more structured approach. Instead
of just mutating a model and calling notifyListeners(), Bloc forces you to describe
state changes as two explicit, separate concepts:
| Concept | What it represents |
|---|---|
| Event | Something that happened — a user tapped a button, a timer fired, an API call finished. Events are input. |
| State | The result — a snapshot of what the UI should show after an event was processed. States are output. |
The Bloc itself is the piece in the middle: it receives events and emits new states in response.
The most common package for this is flutter_bloc. Here's the shape of a minimal
counter Bloc — kept intentionally brief, since Bloc has meaningfully more moving parts and a
steeper learning curve than Provider or Riverpod.
// 1. Events — the things that can happen
abstract class CounterEvent {}
class IncrementPressed extends CounterEvent {}
// 2. State — the result the UI renders
class CounterState {
final int count;
CounterState(this.count);
}
// 3. The Bloc — maps incoming events to new outgoing states
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(CounterState(0)) {
on<IncrementPressed>((event, emit) {
// emit() produces the new state in response to the event
emit(CounterState(state.count + 1));
});
}
}
A widget then dispatches an event with
context.read<CounterBloc>().add(IncrementPressed()) and listens for state changes
with a BlocBuilder. The strict event/state separation is exactly what makes Bloc so
testable — you can test business logic entirely by feeding events into a Bloc and asserting on the
states it emits, with no widgets involved at all — but it does mean more classes and more
boilerplate for the same feature compared to Provider or Riverpod.
Choosing Between setState, Provider, Riverpod, and Bloc
| Approach | Best for | Learning curve |
|---|---|---|
| setState | Simple, local state that belongs to a single widget and nothing else | Lowest — built into every StatefulWidget, no packages needed |
| Provider | Straightforward shared state across multiple widgets, small to mid-sized apps | Gentle — a handful of new concepts (ChangeNotifier, watch/read, Consumer) |
| Riverpod | Everything Provider does, plus state needed outside the widget tree and stronger compile-time guarantees | Moderate — similar vocabulary to Provider, growing rapidly in popularity |
| Bloc | Larger, team-driven codebases where explicit, testable business logic matters more than speed of writing code | Steepest — most structure, most boilerplate, but excellent testability |