Flutter State Management in 2026: Provider vs Riverpod vs Bloc

State management is one of those topics that every Flutter developer eventually has to wrestle with. After building multiple production apps and experimenting with every major solution, I want to share an honest, practical comparison of the three most popular approaches in 2026: Provider, Riverpod, and Bloc.

Why State Management Matters

In Flutter, everything is a widget, and widgets rebuild when their state changes. For small apps, calling setState inside a StatefulWidget works fine. But as your app grows, you hit real problems:

  • Prop drilling. Passing data through multiple widget layers just to reach the one that needs it.
  • Shared state. Multiple widgets need access to the same data, and changes in one should reflect in others.
  • Separation of concerns. Business logic gets tangled with UI code, making testing and maintenance painful.
  • Predictability. Debugging state changes becomes impossible when state mutations happen everywhere.

Let’s look at how each solution tackles these problems. To keep the comparison fair, I’ll implement the same feature with each approach: a simple todo list with add and toggle functionality.

Provider: The Official Recommendation

Provider was the first community package officially recommended by the Flutter team. It builds on top of InheritedWidget, making it feel natural to Flutter developers.

How It Works

You create a ChangeNotifier class that holds your state and notifies listeners when it changes, then provide it to the widget tree using ChangeNotifierProvider.

class TodoNotifier extends ChangeNotifier {
  final List<Todo> _todos = [];

  List<Todo> get todos => List.unmodifiable(_todos);

  void addTodo(String title) {
    _todos.add(Todo(title: title));
    notifyListeners();
  }

  void toggleTodo(int index) {
    _todos[index].isDone = !_todos[index].isDone;
    notifyListeners();
  }
}

Providing it at the top of your widget tree:

ChangeNotifierProvider(
  create: (_) => TodoNotifier(),
  child: const MyApp(),
)

Consuming it in your widgets:

class TodoListScreen extends StatelessWidget {
  const TodoListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final todoNotifier = context.watch<TodoNotifier>();

    return ListView.builder(
      itemCount: todoNotifier.todos.length,
      itemBuilder: (context, index) {
        final todo = todoNotifier.todos[index];
        return CheckboxListTile(
          title: Text(todo.title),
          value: todo.isDone,
          onChanged: (_) => todoNotifier.toggleTodo(index),
        );
      },
    );
  }
}

Pros

  • Low learning curve. If you understand InheritedWidget, Provider feels intuitive.
  • Minimal boilerplate. A ChangeNotifier class and a Provider widget — that’s it.
  • Official Flutter team endorsement. Extensive documentation and community resources.
  • Flexible. Works with ChangeNotifier, ValueNotifier, Stream, or even plain values.

Cons

  • Runtime errors. If you try to read a provider that hasn’t been provided above in the tree, you get a runtime exception — not a compile-time error.
  • Context dependency. You always need a BuildContext to access providers, which makes accessing state from outside the widget tree awkward.
  • Over-notification. ChangeNotifier notifies all listeners on any change. If your state object has 10 properties and you update one, every widget watching that notifier rebuilds.
  • No built-in testing utilities. Testing requires manually setting up the provider tree.

Riverpod: Provider, Reimagined

Riverpod was created by Remi Rousselet — the same developer who created Provider. It addresses nearly every limitation of Provider while introducing a more powerful and flexible API.

How It Works

Riverpod uses global provider declarations that are compile-time safe and don’t depend on the widget tree:

@riverpod
class TodoNotifier extends _$TodoNotifier {
  @override
  List<Todo> build() => [];

  void addTodo(String title) {
    state = [...state, Todo(title: title)];
  }

  void toggleTodo(int index) {
    state = [
      for (int i = 0; i < state.length; i++)
        if (i == index)
          state[i].copyWith(isDone: !state[i].isDone)
        else
          state[i],
    ];
  }
}

Wrapping your app:

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

Consuming in widgets:

class TodoListScreen extends ConsumerWidget {
  const TodoListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todos = ref.watch(todoNotifierProvider);

    return ListView.builder(
      itemCount: todos.length,
      itemBuilder: (context, index) {
        final todo = todos[index];
        return CheckboxListTile(
          title: Text(todo.title),
          value: todo.isDone,
          onChanged: (_) =>
              ref.read(todoNotifierProvider.notifier).toggleTodo(index),
        );
      },
    );
  }
}

Pros

  • Compile-time safety. No more runtime “ProviderNotFoundException.” If a provider doesn’t exist, your code won’t compile.
  • No BuildContext required. Providers can be accessed anywhere using a Ref, making testing and non-UI access straightforward.
  • Fine-grained rebuilds. Using select, you can watch specific parts of state, minimizing unnecessary rebuilds.
  • Built-in support for async. FutureProvider and StreamProvider handle loading and error states elegantly.
  • Auto-dispose. Providers can automatically clean up when no longer listened to.
  • Code generation. The riverpod_generator package reduces boilerplate significantly.

Cons

  • Steeper learning curve. The concepts of Ref, ConsumerWidget, and provider modifiers take time to internalize.
  • Code generation dependency. While not strictly required, the recommended approach relies on build_runner, which adds complexity to your build process.
  • Different paradigm. If your team is used to Provider, the migration requires rethinking how state is structured.
  • Rapid evolution. The API has changed significantly between major versions, which can make finding up-to-date tutorials harder.

Bloc: Enterprise-Grade Predictability

Bloc (Business Logic Component) takes a fundamentally different approach. Instead of mutable state with notifications, Bloc uses a stream-based, event-driven architecture inspired by Redux concepts.

How It Works

You define events (inputs) and states (outputs), and the Bloc maps events to state transitions:

// Events
sealed class TodoEvent {}

class AddTodo extends TodoEvent {
  final String title;
  AddTodo(this.title);
}

class ToggleTodo extends TodoEvent {
  final int index;
  ToggleTodo(this.index);
}

// Bloc
class TodoBloc extends Bloc<TodoEvent, List<Todo>> {
  TodoBloc() : super([]) {
    on<AddTodo>((event, emit) {
      emit([...state, Todo(title: event.title)]);
    });

    on<ToggleTodo>((event, emit) {
      emit([
        for (int i = 0; i < state.length; i++)
          if (i == event.index)
            state[i].copyWith(isDone: !state[i].isDone)
          else
            state[i],
      ]);
    });
  }
}

For simpler cases, Cubit offers a lighter alternative without events:

class TodoCubit extends Cubit<List<Todo>> {
  TodoCubit() : super([]);

  void addTodo(String title) {
    emit([...state, Todo(title: title)]);
  }

  void toggleTodo(int index) {
    emit([
      for (int i = 0; i < state.length; i++)
        if (i == index)
          state[i].copyWith(isDone: !state[i].isDone)
        else
          state[i],
    ]);
  }
}

Consuming in widgets:

class TodoListScreen extends StatelessWidget {
  const TodoListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<TodoBloc, List<Todo>>(
      builder: (context, todos) {
        return ListView.builder(
          itemCount: todos.length,
          itemBuilder: (context, index) {
            final todo = todos[index];
            return CheckboxListTile(
              title: Text(todo.title),
              value: todo.isDone,
              onChanged: (_) =>
                  context.read<TodoBloc>().add(ToggleTodo(index)),
            );
          },
        );
      },
    );
  }
}

Pros

  • Predictable state transitions. Every state change flows through a defined path: Event → Bloc → State. This makes debugging straightforward.
  • Excellent testability. Testing a Bloc is pure logic testing — fire events, assert states. No widget tree needed.
  • BlocObserver. A global observer lets you log, track, or react to every state change across your entire app. Invaluable for debugging.
  • Scales well. The enforced structure keeps large codebases organized.
  • Strong community. Comprehensive documentation, many tutorials, and active maintenance.

Cons

  • Boilerplate. The event-state pattern requires more code than Provider or Riverpod for simple features.
  • Overkill for simple apps. If you’re building a small app, the Bloc architecture can feel like driving a semi truck to the grocery store.
  • Learning curve. Understanding streams, events, states, and the overall pattern takes more initial investment.
  • Event overhead. For simple state changes, creating separate event classes feels unnecessary (though Cubit mitigates this).

Comparison Table

Feature Provider Riverpod Bloc
Learning curve Low Medium Medium-High
Boilerplate Low Low-Medium High
Compile-time safety No Yes Partial
Testing ease Moderate Excellent Excellent
Async support Manual Built-in Built-in
Scalability Moderate High High
Performance control Limited Fine-grained Good
DevTools support Basic Good Excellent
Code generation needed No Recommended No

When to Use Which

Choose Provider when:

  • You’re building a small to medium app
  • Your team is new to Flutter and needs something simple
  • You want minimal dependencies and setup

Choose Riverpod when:

  • You want compile-time safety and better testing
  • Your app has complex dependency chains between different state objects
  • You need fine-grained control over rebuilds
  • You’re starting a new project and can invest in learning the API

Choose Bloc when:

  • You’re working on a large or enterprise application
  • Your team values strict architectural patterns
  • You need comprehensive state change logging and debugging
  • You’re working in a team environment where consistency matters

What I Use in My Own Apps

For my indie apps like Happy Balloon Pop, I use Riverpod. Here’s why:

My apps are small to medium in size, which rules out Bloc’s overhead. But they have enough complexity — managing game state, user preferences, ad loading, and in-app purchases — that plain Provider starts to show its limitations.

Riverpod’s compile-time safety has saved me from bugs multiple times. Its auto-dispose feature is perfect for game screens where state should reset when the user navigates away. And the ability to access providers without BuildContext is essential when I need to interact with state from platform channels or background isolates.

That said, I started with Provider and migrated to Riverpod gradually. If you’re just beginning your Flutter journey, there’s nothing wrong with starting with Provider and growing into Riverpod as your needs evolve.

Migration Tips

If you’re moving from Provider to Riverpod:

  1. Start with new features. Don’t rewrite existing code immediately. Use Riverpod for new screens and features.
  2. Both can coexist. Provider and Riverpod can live in the same app during migration.
  3. Convert ChangeNotifiers first. Your existing ChangeNotifier classes can work with Riverpod’s ChangeNotifierProvider as a stepping stone.
  4. Adopt code generation gradually. You can use Riverpod without code generation initially, then add it as you get comfortable.

If you’re moving from Provider to Bloc:

  1. Start with Cubit. It’s conceptually closer to Provider’s ChangeNotifier and requires less restructuring.
  2. Extract business logic. Move logic out of widgets into Cubit/Bloc classes one screen at a time.
  3. Add events later. Once you’re comfortable with Cubit, convert high-complexity features to full Bloc with events for better traceability.

The best state management solution is the one your team understands and can maintain. Don’t chase trends — pick the approach that fits your project’s complexity, your team’s experience, and your long-term maintenance needs. Start simple, and add complexity only when the problems it solves are problems you actually have.