State Management in Flutter: Provider vs Riverpod vs Bloc, Which One to Choose?

Every Flutter beginner hits the same wall sooner or later: which state management solution should I use? Provider, Riverpod, Bloc, GetX and plain setState all have loud fans, and every tutorial seems to crown a different winner. This guide cuts through that. You’ll see what state management actually is, build the same counter app with Provider, Bloc and Riverpod, compare how Riverpod and Bloc handle the same everyday tasks, and finish with a practical rule for choosing, based on real project experience rather than hype.

What state management actually is

Imagine a simple counter app: the user taps a button and the number goes up. Easy. Now imagine that number has to show on a second screen too. Suddenly the data needs to live somewhere both screens can reach, and both screens must update when it changes.

That is state management in one sentence: whenever your data changes (an API response arrives, a user taps a button, anything), the UI should update automatically. It is the brain of your app, tracking changing data and keeping the screen in sync with it.

So why so many tools for one job? Because apps differ in complexity:

  • Small apps (a calculator, a notes app): setState is enough. Don’t force Bloc onto a small project.
  • Medium apps with several screens, shared data and user authentication: Provider or Riverpod feels much cleaner.
  • Large production apps with big teams and features shipping every week: you want a predictable, traceable architecture, which is where Bloc-style solutions shine.

Meet the three contenders

Package Core idea Best known for
Provider Providers hold data, consumers read it. Think of a global announcement system where every widget can hear the latest update. Simplicity and a gentle learning curve
Bloc Strict separation of concerns: you send events, the Bloc reacts and emits new states, and the UI rebuilds. Structure, organization and traceability in large apps
Riverpod A lightweight, provider-like system with its own state objects, combining dependency injection and state management. Flexibility, less boilerplate, usable without a widget tree

The best way to feel the difference is to build the same thing three times. Each example below is a counter shared across two screens, with a button to navigate from the first screen to the second.

Round 1: Provider

Install the package:

flutter pub add provider

You create a class that holds the counter value and a method to increment it. Widgets that need the value simply watch it.

lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class CounterProvider extends ChangeNotifier {
  int count = 0;

  void increment() {
    count++;
    notifyListeners(); // tells listening widgets to rebuild
  }
}

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CounterProvider(),
      child: const MaterialApp(home: HomePage()),
    ),
  );
}

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

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

    return Scaffold(
      body: Center(child: Text('$count', style: const TextStyle(fontSize: 48))),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            heroTag: 'inc',
            onPressed: () => context.read<CounterProvider>().increment(),
            child: const Icon(Icons.add),
          ),
          const SizedBox(height: 12),
          FloatingActionButton(
            heroTag: 'next',
            onPressed: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const PageTwo()),
            ),
            child: const Icon(Icons.arrow_forward),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Consumer<CounterProvider>(
          builder: (context, counter, _) =>
              Text('${counter.count}', style: const TextStyle(fontSize: 48)),
        ),
      ),
    );
  }
}

Two things to notice. context.watch rebuilds the widget whenever the value changes, while context.read just grabs the object for one-time actions like a button press. On the second screen, a Consumer widget does the same job as watch but limits the rebuild to just its builder.

Run it and the counter stays in sync across both screens. That’s the appeal of Provider: very little code. The catch shows up in bigger apps. Once you have many providers, things get messy, and debugging can turn into a detective game.

Round 2: Bloc

Bloc forces you to be explicit. You define events (what happened), states (what the screen should show), and a Bloc that turns one into the other.

flutter pub add flutter_bloc
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

// Events
abstract class CounterEvent {}
class Increment extends CounterEvent {}
class Decrement extends CounterEvent {}

// Bloc: state here is just the counter value
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
    on<Decrement>((event, emit) => emit(state - 1));
  }
}

void main() {
  runApp(
    BlocProvider(
      create: (_) => CounterBloc(),
      child: const MaterialApp(home: CounterView()),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: BlocBuilder<CounterBloc, int>(
          builder: (context, count) =>
              Text('$count', style: const TextStyle(fontSize: 48)),
        ),
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            heroTag: 'inc',
            onPressed: () => context.read<CounterBloc>().add(Increment()),
            child: const Icon(Icons.add),
          ),
          const SizedBox(height: 12),
          FloatingActionButton(
            heroTag: 'dec',
            onPressed: () => context.read<CounterBloc>().add(Decrement()),
            child: const Icon(Icons.remove),
          ),
          const SizedBox(height: 12),
          FloatingActionButton(
            heroTag: 'next',
            onPressed: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const PageTwo()),
            ),
            child: const Icon(Icons.arrow_forward),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: BlocBuilder<CounterBloc, int>(
          builder: (context, count) =>
              Text('$count', style: const TextStyle(fontSize: 48)),
        ),
      ),
    );
  }
}

The flow is always the same: the UI adds an event, the Bloc handles it and emits a new state, and BlocBuilder rebuilds. Everything is traceable, which is exactly why large teams like it. The price is boilerplate: for a plain counter it can feel like using a sledgehammer to crack a nut.

Tip: If you don’t need the event layer, Bloc’s lighter sibling Cubit lets you call methods directly (context.read<CounterCubit>().increment()) and emit states without defining event classes.

Round 3: Riverpod

Riverpod takes a provider-based approach, similar to Provider, but state lives in providers you declare yourself and access through a ref object.

flutter pub add flutter_riverpod
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class CounterNotifier extends Notifier<int> {
  @override
  int build() => 0; // initial value

  void increment() => state++;
}

final counterProvider =
    NotifierProvider<CounterNotifier, int>(CounterNotifier.new);

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

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

  @override
  Widget build(BuildContext context) => const MaterialApp(home: Home());
}

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

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

    return Scaffold(
      body: Center(child: Text('$count', style: const TextStyle(fontSize: 48))),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            heroTag: 'inc',
            onPressed: () => ref.read(counterProvider.notifier).increment(),
            child: const Icon(Icons.add),
          ),
          const SizedBox(height: 12),
          FloatingActionButton(
            heroTag: 'next',
            onPressed: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const PageTwo()),
            ),
            child: const Icon(Icons.arrow_forward),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Scaffold(
      body: Center(child: Text('$count', style: const TextStyle(fontSize: 48))),
    );
  }
}

The root is wrapped in a ProviderScope, which stores the state of every provider. Widgets read state with ref.watch and trigger changes with ref.read(...).increment(). Riverpod is lightweight and easy to learn, though it is the younger option, so you will find fewer older tutorials and a smaller body of community answers than for the others.

Version note: Riverpod’s API has evolved over time (older tutorials use StateNotifier and StateNotifierProvider, newer ones use Notifier and code generation). If an example you copy doesn’t compile, check which Riverpod version the tutorial was written for.

Riverpod vs Bloc: task by task

“Riverpod or Bloc?” is one of the most common beginner questions, but comparing them head-on isn’t strictly fair. Both can be split into two jobs: dependency injection (making objects available to widgets) and state management (holding and changing state). Bloc builds its dependency injection on top of the Provider package and stores state in Bloc or Cubit classes. Riverpod is essentially Provider 2.0 for dependency injection, with state objects bundled in.

At the state-holding level they are very close. A Cubit and a Riverpod state notifier look almost identical, and both are built on streams, a concept already proven in real-world apps. The differences appear in everyday tasks:

Task Bloc Riverpod
Rebuild a widget when state changes BlocBuilder finds the closest Bloc of a given type up the widget tree and subscribes to it Consumer or ConsumerWidget gives you a ref and you watch a specific provider. You can use ConsumerWidget in place of StatelessWidget and ConsumerStatefulWidget in place of StatefulWidget
Rebuild only on a part of the state BlocSelector Select just the part you need when watching the provider
React without rebuilding (e.g. show a snackbar) BlocListener ref.listen instead of ref.watch
Change state from the UI Send an event to the Bloc; you write an event class for each action Call a method on the notifier via ref.read, typically for one-time actions like button presses
Provide objects to the tree BlocProvider shares one instance with a whole subtree Wrap the root in ProviderScope, which stores all provider state

Bottom line: both are excellent, and which is “better” depends on the scale of the project and the developer’s personal preference.

Why architecture matters more than the package

Here is the part most comparison videos skip. A developer with around a decade of mobile experience (React Native, Svelte, React and Flutter) found that choosing a state management package is not the thing to worry about. Packages change: we had Provider, now many people are moving to Riverpod, and something better may come later. What doesn’t change is the structure of your project.

Their real-world example: an app with 40+ features and 70+ files sitting in one folder. Things were messy, several providers were used by a single screen, and Provider got the blame. The actual fix wasn’t a new package. It was switching to a feature-based architecture: a features folder inside lib, with every feature in its own folder holding all of its widgets and logic.

lib/
  features/
    auth/
      data/
      providers/
      screens/
      widgets/
    cart/
      data/
      providers/
      screens/
      widgets/
    profile/
      ...
  core/
    services/
    utils/
  main.dart

The benefits were immediate: open one folder and work on one feature, use a separate branch per feature, and split a feature into more files and folders as it grows. Most importantly, migration got easier. Because each feature is self-contained, you can migrate one feature at a time instead of rewriting everything at once.

Practical takeaway: If your project is already messy, don’t rush to swap packages. Fix the folder structure first. A clean structure makes any later migration (Provider to Riverpod, or to whatever comes next) a gradual job instead of a rewrite.

A lesson from GetX

One developer’s story is a useful warning. Their first serious Flutter app took six months to build, and when it was time to add a new feature, the state management had become so messy that understanding the old code came before writing new code. Their senior’s verdict: rewrite it.

They had picked GetX after a video claimed it was the best option for less code and faster development. At first that was true: less code, easy setup, smooth progress. But as the app grew and three or four developers joined the team, complexity rose. Without a clear architecture and discipline, it became hard to tell where state came from and where it was being updated, and debugging took longer.

The key insight: the problem wasn’t GetX. The problem was the lack of planning and a scalable structure. Whatever tool you pick (GetX, Bloc, Riverpod), choose it with a clear architecture and a long-term view, so that growing teams don’t cause trouble later. Planning properly at the start means far less cost, time and refactoring later.

So which one should you choose?

There is no single winner, but there is a sensible decision path.

Your situation Recommendation
Tiny app, one or two screens setState is enough. Don’t over-engineer.
Learning Flutter, small to medium app Provider for ease of use, or Riverpod if you want to learn the tool many developers are moving toward.
New project, no existing code Riverpod is a strong default: it handles both state and services (singletons, factories) in one system, so you don’t need extra packages like get_it just to manage services.
Large app, big team, strict conventions Bloc. Its structure and traceability pay off, even though it has a steeper learning curve and more boilerplate.
Existing app that already works Don’t migrate in a panic. Fix the architecture first, then migrate feature by feature if you need to.
Your team already knows one of these well Stay with it. Shipping the product matters more than picking the “perfect” package.

A few more points worth remembering:

  • Launch first, optimize later. Spending weeks learning a new state management package can slow you down when you could be building with what you already know. Once the product finds its market, you can switch to something better.
  • Migration is normal. Early code is always messy because you’re experimenting. Coming back to features later gives you a chance to improve them.
  • Use generators with Riverpod. Wiring providers up manually at scale gets messy quickly, and code generation keeps things consistent.
  • AI reduces the boilerplate problem. Writing lots of repetitive code is much less painful now that AI tools can generate it. But AI can’t design your architecture for you. That part is still your job, so review what it produces and keep features on their own branches.
  • Experiment. Plenty of resources exist for each package, and the best solution is the one that fits your needs and feels comfortable.

Common mistakes and gotchas

Symptom Likely cause
Code gets harder to understand as the app grows No clear architecture. Switch to a feature-based folder structure before blaming the package.
Debugging takes ages because you can’t tell where state changes State is updated from too many places without discipline. A more structured approach such as Bloc, or clear rules for who may update what, helps.
Bloc feels like too much code for a small app Wrong tool for the size. Use Cubit, Provider or Riverpod instead.
Many providers used by a single screen get messy Feature files scattered across the project. Group them by feature.
Widget rebuilds more often than expected You are watching the whole state. Use BlocSelector or select only the part of the Riverpod state you need.
Provider complains it can’t find the object The widget isn’t under the provider in the tree. Wrap the correct ancestor (or the whole app) with the provider.
Riverpod code from an old tutorial won’t compile API changed between versions. Match the tutorial to your installed version.

Frequently asked questions

Which is the best state management in Flutter?

There isn’t one best option for every project. Provider suits simple apps, Bloc suits large structured ones, and Riverpod is a good middle ground and a popular default for new projects. The right choice depends on app size, team experience and long-term plans.

Is Riverpod better than Bloc?

Not strictly. Both are excellent and can both build scalable apps. Riverpod has less boilerplate and a lighter learning curve, while Bloc gives a very explicit event-and-state structure that many large teams value.

Is Provider outdated?

Many developers consider Riverpod its more scalable successor, since Riverpod also manages services and avoids some of Provider’s mess when the same provider is shared across many screens. Provider still works fine for small and medium apps, and many existing apps use it.

Should I use setState or a package?

For very small apps, setState is enough. Move to a package when data must be shared across screens or when logic is getting tangled inside widgets.

Should I migrate my old project to Riverpod?

Not immediately. First clean up the folder structure (feature-based works well), then migrate one feature at a time when you have the time and the product has proven itself.

Is Bloc too complex for beginners?

It has a steeper learning curve because of events, states and extra files. If that slows you down, start with Cubit, Provider or Riverpod and move to Bloc later if your project needs it.

Is GetX a bad choice?

Not by itself. It makes early development fast, but without clear architecture and discipline a growing app and team can lose track of where state lives. That is a planning problem that can happen with any tool.

How do I choose if I’m starting a new project today?

Pick the one your team already knows, or Riverpod if nobody has a preference. Set up a feature-based structure from day one and keep your state logic separate from your UI.

Conclusion

State management in Flutter looks like a confusing pile of options, but the job is always the same: keep your UI in sync with your data. Provider gives you simplicity, Bloc gives you structure, and Riverpod gives you a flexible middle ground. Each one can build a scalable app, and none will save a project that has no plan.

The real advice from developers who’ve been through rewrites is this: fix your architecture first, then use the package you and your team know best. Use a feature-based structure, keep logic out of your widgets, and don’t be afraid to migrate later one feature at a time. Ship your app, get your first users, and let real needs, not internet arguments, decide when to upgrade.

Leave a Reply

Your email address will not be published. Required fields are marked *