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:

ImprovementWhy it matters
No BuildContext requiredProvider'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 safetyWith 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:

ConceptWhat it represents
EventSomething that happened — a user tapped a button, a timer fired, an API call finished. Events are input.
StateThe 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

ApproachBest forLearning curve
setStateSimple, local state that belongs to a single widget and nothing elseLowest — built into every StatefulWidget, no packages needed
ProviderStraightforward shared state across multiple widgets, small to mid-sized appsGentle — a handful of new concepts (ChangeNotifier, watch/read, Consumer)
RiverpodEverything Provider does, plus state needed outside the widget tree and stronger compile-time guaranteesModerate — similar vocabulary to Provider, growing rapidly in popularity
BlocLarger, team-driven codebases where explicit, testable business logic matters more than speed of writing codeSteepest — most structure, most boilerplate, but excellent testability
Honest take: there's no single "best" answer here — plenty of production apps ship successfully on Provider alone. Pick the simplest tool that solves the problem in front of you, and only reach for Bloc's extra structure when a team or codebase genuinely needs it.
Next: Sharing state in memory is only half the picture — continue to Local Storage to learn how to persist data on the device itself, so it survives an app restart.