Mobile State Management: Provider vs Riverpod vs BLoC

Mobile State Management: Provider vs Riverpod vs BLoC

I’ve rewritten the same app’s state layer three times. Each time it got simpler. Not because the app shrank — the feature set actually grew between rewrites — but because I kept finding approaches that matched my mental model better. First came Provider, then Riverpod swooped in, and somewhere around mid-2024 a colleague dropped BLoC into our codebase and suddenly we were debating architecture at lunch again.

Maybe you’re at a similar crossroads. Flutter’s ecosystem doesn’t lack state management solutions. If anything, there are too many. But three have risen above the noise for production work: Provider, Riverpod, and BLoC. And honestly? Picking between them isn’t about which one is “best.” It’s about which one fits your team, your testing habits, your tolerance for ceremony.

So here’s what I want to do. We’ll build the exact same feature — a weather lookup screen — using all three approaches. Same data layer, same UI outcome, different plumbing underneath. By the end, you’ll have seen enough Dart to form your own opinions. And I’ll circle back to that rewriting story, because there’s a punchline you might not expect.

Ground Rules: One Data Layer to Rule Them

Before we touch any state management code, let’s establish the shared foundation. Both the model and the repository stay identical across all three implementations. Keeps things fair. Keeps the comparison honest.

// models/weather.dart
class Weather {
  final String city;
  final double temperature;
  final String condition;
  final int humidity;

  const Weather({
    required this.city,
    required this.temperature,
    required this.condition,
    required this.humidity,
  });

  factory Weather.fromJson(Map<String, dynamic> json) {
    return Weather(
      city: json['name'] as String,
      temperature: (json['main']['temp'] as num).toDouble(),
      condition: json['weather'][0]['description'] as String,
      humidity: json['main']['humidity'] as int,
    );
  }
}

// repositories/weather_repository.dart
import 'dart:convert';
import 'package:http/http.dart' as http;

class WeatherRepository {
  final String _apiKey;
  final http.Client _client;

  WeatherRepository({required String apiKey, http.Client? client})
      : _apiKey = apiKey,
        _client = client ?? http.Client();

  Future<Weather> getWeather(String city) async {
    final uri = Uri.parse(
      'https://api.openweathermap.org/data/2.5/weather'
      '?q=$city&appid=$_apiKey&units=metric',
    );
    final response = await _client.get(uri);

    if (response.statusCode != 200) {
      throw Exception('Failed to fetch weather for $city');
    }

    return Weather.fromJson(jsonDecode(response.body));
  }
}

Nothing controversial here. A Weather model with JSON parsing, and a repository that talks to OpenWeatherMap. We’re injecting an http.Client so tests can mock network calls — a pattern you’ll see pay off later when we compare how each approach handles testability.

Round One: Provider — Where Most People Start

Provider wraps Flutter’s InheritedWidget in a friendlier API. You’ve probably bumped into it if you’ve followed any “getting started with Flutter” tutorial from the last few years. Google’s own Flutter team recommends it as a starting point, and for good reason: there’s barely any boilerplate.

Your state lives in a ChangeNotifier. Mutate some fields, call notifyListeners(), and any widget watching that provider rebuilds automatically. Feels almost magical the first time you wire it up.

// provider/weather_provider.dart
import 'package:flutter/foundation.dart';

class WeatherProvider extends ChangeNotifier {
  final WeatherRepository _repo;

  WeatherProvider(this._repo);

  Weather? _weather;
  Weather? get weather => _weather;

  bool _isLoading = false;
  bool get isLoading => _isLoading;

  String? _error;
  String? get error => _error;

  Future<void> fetchWeather(String city) async {
    _isLoading = true;
    _error = null;
    notifyListeners();

    try {
      _weather = await _repo.getWeather(city);
    } catch (e) {
      _error = e.toString();
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

// provider/weather_screen_provider.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

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

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<WeatherProvider>();
    final controller = TextEditingController();

    return Scaffold(
      appBar: AppBar(title: const Text('Weather (Provider)')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              controller: controller,
              decoration: InputDecoration(
                hintText: 'Enter city name',
                suffixIcon: IconButton(
                  icon: const Icon(Icons.search),
                  onPressed: () => provider.fetchWeather(controller.text),
                ),
              ),
            ),
            const SizedBox(height: 24),
            if (provider.isLoading) const CircularProgressIndicator(),
            if (provider.error != null)
              Text(provider.error!, style: const TextStyle(color: Colors.red)),
            if (provider.weather != null)
              _WeatherCard(weather: provider.weather!),
          ],
        ),
      ),
    );
  }
}

Read through that screen widget. Pretty lean, right? context.watch subscribes to changes, the conditional if statements handle loading/error/data states inline, and the whole thing feels like regular Flutter. No new paradigms to absorb. No unfamiliar base classes beyond ChangeNotifier.

Where Provider Shines

Small to medium apps. Teams that are newer to Flutter. Prototypes where you’d rather ship something tomorrow than architect something perfect. Provider’s learning curve is genuinely gentle — I’ve seen junior developers productive with it inside an afternoon.

Where Provider Gets Uncomfortable

Here’s the catch, though. Provider lookups happen at runtime through the widget tree. If you forget to register a provider above the widget that needs it, you won’t know until the app crashes. No compiler warning. No red squiggly in your IDE. Just a runtime exception and a stack trace that points at context.watch or context.read.

On a team of three, that’s manageable. On a team of fifteen working across dozens of screens? Runtime provider-not-found errors start cropping up in QA at inconvenient moments. I’ve been there. It’s… not fun.

And testing, while possible, requires wrapping everything in the correct widget tree with the right providers registered. Not terrible, but fiddly enough that people sometimes skip writing tests for provider-heavy code. Which kind of defeats the purpose.

Round Two: Riverpod — Provider’s Ambitious Successor

Remi Rousselet created both Provider and Riverpod. Riverpod is basically what happens when a library author says, “Okay, I know what went wrong the first time.” The name itself is an anagram of Provider — a cheeky nod to its origins.

Biggest shift? No more BuildContext dependency. Providers are declared as global top-level variables. Sounds wild at first — global state? really? — but Riverpod’s provider system gives you compile-time safety, automatic disposal, and a built-in AsyncValue type that elegantly handles loading, error, and data states without you writing boilerplate conditionals.

// riverpod/weather_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
  return WeatherRepository(apiKey: 'YOUR_API_KEY');
});

final weatherProvider =
    AsyncNotifierProvider<WeatherNotifier, Weather?>(WeatherNotifier.new);

class WeatherNotifier extends AsyncNotifier<Weather?> {
  @override
  Future<Weather?> build() async => null;

  Future<void> fetchWeather(String city) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() {
      final repo = ref.read(weatherRepositoryProvider);
      return repo.getWeather(city);
    });
  }
}

// riverpod/weather_screen_riverpod.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final weatherAsync = ref.watch(weatherProvider);
    final controller = TextEditingController();

    return Scaffold(
      appBar: AppBar(title: const Text('Weather (Riverpod)')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              controller: controller,
              decoration: InputDecoration(
                hintText: 'Enter city name',
                suffixIcon: IconButton(
                  icon: const Icon(Icons.search),
                  onPressed: () => ref
                      .read(weatherProvider.notifier)
                      .fetchWeather(controller.text),
                ),
              ),
            ),
            const SizedBox(height: 24),
            weatherAsync.when(
              data: (weather) => weather != null
                  ? _WeatherCard(weather: weather)
                  : const Text('Search for a city'),
              loading: () => const CircularProgressIndicator(),
              error: (err, _) =>
                  Text('$err', style: const TextStyle(color: Colors.red)),
            ),
          ],
        ),
      ),
    );
  }
}

Notice the .when() method on the weather async value. Instead of juggling three booleans — isLoading, hasError, hasData — Riverpod forces you into handling all three states explicitly. Miss one and the compiler yells at you. I can’t overstate how much this reduces bugs that slip through testing.

Where Riverpod Shines

Testability, hands down. You can create a ProviderContainer, override any provider with a mock, and run your logic in pure Dart without ever touching Flutter’s widget layer. Feature flags? A/B tests? Provider overrides make these almost trivial. We adopted Riverpod around late 2023 on a fintech project, and our test coverage jumped from maybe 40% to somewhere north of 75% within two months. Not because we suddenly became disciplined — the framework just made testing less painful.

Compile-time safety matters too. If you typo a provider name or try to read a provider that doesn’t exist, the analyzer catches it before you even run the app. Coming from Provider’s runtime errors, this felt like upgrading from a bicycle to a motorcycle.

Where Riverpod Gets Uncomfortable

Concepts. So many concepts. Provider, StateProvider, FutureProvider, StreamProvider, NotifierProvider, AsyncNotifierProvider… the taxonomy is large. A developer new to Flutter state management faces a significant learning curve. Even experienced Dart developers I’ve worked with needed a solid week before Riverpod’s mental model “clicked.”

And documentation, while improving, has historically been… scattered? Remi’s been doing great work consolidating it on riverpod.dev, but stack overflow answers for Riverpod 1.x don’t always translate cleanly to 2.x. You’ll probably find yourself confused by outdated examples at least a few times. I certainly was.

Round Three: BLoC — The Enterprise Contender

BLoC stands for Business Logic Component, and it enforces something very specific: unidirectional data flow. Events go in, states come out. No exceptions. No shortcuts. No “just call a method and mutate a field.”

Where Provider and Riverpod let you be a bit informal about how state changes propagate, BLoC demands ceremony. You define event classes. You define state classes. You wire them together in a bloc that maps events to state transitions. It’s the most verbose of the three, and that verbosity is a feature, not a bug — especially when your team has fifteen developers who need to understand each other’s code.

// bloc/weather_event.dart
abstract class WeatherEvent {}

class FetchWeather extends WeatherEvent {
  final String city;
  FetchWeather(this.city);
}

// bloc/weather_state.dart
abstract class WeatherState {}

class WeatherInitial extends WeatherState {}

class WeatherLoading extends WeatherState {}

class WeatherLoaded extends WeatherState {
  final Weather weather;
  WeatherLoaded(this.weather);
}

class WeatherError extends WeatherState {
  final String message;
  WeatherError(this.message);
}

// bloc/weather_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';

class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
  final WeatherRepository _repo;

  WeatherBloc(this._repo) : super(WeatherInitial()) {
    on<FetchWeather>(_onFetchWeather);
  }

  Future<void> _onFetchWeather(
    FetchWeather event,
    Emitter<WeatherState> emit,
  ) async {
    emit(WeatherLoading());
    try {
      final weather = await _repo.getWeather(event.city);
      emit(WeatherLoaded(weather));
    } catch (e) {
      emit(WeatherError(e.toString()));
    }
  }
}

// bloc/weather_screen_bloc.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

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

  @override
  Widget build(BuildContext context) {
    final controller = TextEditingController();

    return Scaffold(
      appBar: AppBar(title: const Text('Weather (BLoC)')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              controller: controller,
              decoration: InputDecoration(
                hintText: 'Enter city name',
                suffixIcon: IconButton(
                  icon: const Icon(Icons.search),
                  onPressed: () => context
                      .read<WeatherBloc>()
                      .add(FetchWeather(controller.text)),
                ),
              ),
            ),
            const SizedBox(height: 24),
            BlocBuilder<WeatherBloc, WeatherState>(
              builder: (context, state) {
                if (state is WeatherLoading) {
                  return const CircularProgressIndicator();
                }
                if (state is WeatherError) {
                  return Text(state.message,
                      style: const TextStyle(color: Colors.red));
                }
                if (state is WeatherLoaded) {
                  return _WeatherCard(weather: state.weather);
                }
                return const Text('Search for a city');
              },
            ),
          ],
        ),
      ),
    );
  }
}

Look at the event/state separation. When someone six months from now reads this code, they’ll know exactly what can happen (events) and exactly what outcomes are possible (states). There’s no guessing about whether some random method mutates something behind the scenes. Every state transition is explicit, traceable, logged if you want it to be.

Where BLoC Shines

Large teams and enterprise apps. Full stop. BLoC’s bloc_test package makes writing unit tests almost mechanical — you declare an initial state, fire events, and assert the resulting state sequence. Code reviews become cleaner because the event-state contract is self-documenting. And BlocObserver gives you a single hook to log every state change across your entire application, which is a godsend for debugging production issues.

Around early 2025, a friend working at a banking startup in Bangalore told me their team of twenty Flutter developers adopted BLoC specifically because onboarding became faster. New hires could trace any feature’s logic by reading events and states without understanding the full widget tree. That story stuck with me.

Where BLoC Gets Uncomfortable

Boilerplate. For a simple feature like our weather lookup, BLoC requires four separate files: events, states, the bloc itself, and the screen. Provider did it in two. Riverpod in two (if you colocate your providers). When you’re prototyping or building a small app, BLoC’s structure feels like wearing a suit to a barbecue.

There’s also a conceptual overhead around StreamController internals, event transformers, and how BLoC handles concurrent events. You don’t need to understand streams deeply to use BLoC — the flutter_bloc package abstracts most of it away — but when something goes wrong, debugging requires that stream-level knowledge. I’ve spent more than one late evening tracing why an event handler wasn’t emitting states in the expected order.

The Shared Display Widget

All three screens render weather data using the same card widget. Keeping this shared makes it crystal clear that the UI differences come from state management choices, not presentation logic:

class _WeatherCard extends StatelessWidget {
  final Weather weather;
  const _WeatherCard({required this.weather});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          children: [
            Text(weather.city,
                style: const TextStyle(
                    fontSize: 24, fontWeight: FontWeight.bold)),
            const SizedBox(height: 8),
            Text('${weather.temperature.toStringAsFixed(1)}°C',
                style: const TextStyle(fontSize: 48)),
            Text(weather.condition, style: const TextStyle(fontSize: 16)),
            const SizedBox(height: 8),
            Text('Humidity: ${weather.humidity}%'),
          ],
        ),
      ),
    );
  }
}

Head-to-Head: What Actually Matters

Code samples are nice, but let’s talk about the dimensions that actually drive decisions on real projects. I’ve grouped them into categories that come up in every architecture discussion I’ve been part of.

Learning Curve

Provider is the gentlest on-ramp. You need to understand ChangeNotifier and the context.watch/context.read split. That’s roughly it. Most developers I’ve mentored were productive within a day.

Riverpod takes longer. Plan for a week of “what even is an AsyncNotifierProvider?” confusion before things stabilize. Once it clicks, though, the power is undeniable.

BLoC requires understanding events, states, the event-to-state mapping pattern, and ideally some familiarity with reactive streams. Could take two weeks before a team feels fully comfortable. Worth it at scale, potentially overkill for smaller projects.

Testability

Riverpod wins here, and it’s not particularly close. Provider overrides via ProviderContainer let you test business logic in pure Dart without any Flutter widget tree. Provider requires wrapping widgets with the correct providers. BLoC’s bloc_test is excellent for unit testing blocs in isolation, but integrating those tests with the UI layer involves BlocProvider scaffolding.

If testing is your north star — and on fintech or healthcare apps, it should be — Riverpod gives you the cleanest path to high coverage.

Scalability

BLoC was designed for large codebases. Event-state contracts act as de facto documentation. When your app has 80+ screens and 30+ developers, the boilerplate stops feeling like overhead and starts feeling like guardrails. BLoC Observer lets architects monitor state transitions application-wide. That kind of observability doesn’t come free with the other two.

Riverpod scales well too — the compile-time safety and provider scoping handle complexity gracefully. Provider can scale, but it tends to get messy around the 40-screen mark when provider dependencies form a tangled tree.

Boilerplate

Let’s be blunt about this. Our weather feature required roughly 30 lines of state management code with Provider. Riverpod came in around 25 (thanks to AsyncValue doing double duty). BLoC needed about 55 lines across events, states, and the bloc class. That ratio holds pretty consistently across real apps.

Now, is less code always better? Not necessarily. BLoC’s extra lines buy you explicitness. But for a solo developer or a two-person team, writing four files for every feature gets tedious fast. I know because I did it. For about three months in 2024, before switching that project to Riverpod.

Community and Ecosystem

Provider has Google’s implicit endorsement and years of stack overflow answers. BLoC has a massive community, especially in enterprise Flutter shops across Southeast Asia and India. Riverpod’s community is smaller but intensely active, and it’s growing quickly. Pub.dev download numbers as of early 2026 show all three in the top tier of Flutter packages.

When I’d Reach for Each One

After building production apps with all three — and rewriting that one stubborn app’s state layer three times — here’s my rough decision tree. It’s subjective, and your mileage will vary.

Provider when you’re learning Flutter, building a hackathon project, or prototyping something that might get thrown away. Low ceremony, fast results. You can always migrate later.

Riverpod when you care about testing, need compile-time safety, or are building a medium-to-large app where provider dependencies get complex. It’s Provider’s evolution, and for most new production projects in 2026, it’s probably where I’d start.

BLoC when your team is large, your app is complex, and you need explicit event-state contracts that make code reviews and onboarding frictionless. Enterprise apps, fintech, healthcare — anywhere that auditability and traceability trump developer convenience.

Could you mix them? Technically, yes. I’ve seen codebases use Provider for simple ephemeral state (like form input) and BLoC for core business logic. Whether that’s pragmatic or chaotic depends on how disciplined your team is about boundaries. I’d lean toward picking one and committing, but I’ve been wrong before.

Common Pitfalls I’ve Hit (So You Don’t Have To)

With Provider: forgetting to call notifyListeners() after a state change. Your UI just… doesn’t update. You stare at it for twenty minutes wondering what’s broken. Then you see the missing line and feel briefly silly. Happened to me more than once.

With Riverpod: using ref.read when you meant ref.watch, or vice versa. Read is for one-time access (event handlers, button callbacks). Watch is for reactive rebuilds. Swap them and you’ll either get stale data or unnecessary rebuilds. The mental model takes a bit to internalize.

With BLoC: emitting states after the bloc has been closed. If a user navigates away while an async operation is in-flight, the bloc might try to emit a new state after disposal. The isClosed check in your event handlers saves you from some nasty crashes. Learned that one the hard way on a production app around February 2025.

What About GetX, MobX, and the Others?

You might be wondering why I’m ignoring other state management solutions. GetX has vocal advocates. MobX has a loyal following among developers coming from React Native. Redux exists in Dart form. Signals are gaining attention.

I’m not dismissing any of them. But Provider, Riverpod, and BLoC dominate production Flutter apps in 2026 by a wide margin. They’re the three you’ll most likely encounter when joining an existing project or reviewing open-source codebases. Mastering one of these three gives you the vocabulary to understand the others quickly.

Coming Full Circle

Remember how I said I’ve rewritten the same app’s state layer three times? Here’s the part I didn’t mention: the app got better each time, but not because of the state management library. It got better because each rewrite forced me to think more carefully about where state actually lives. Which data is truly global? What’s ephemeral? What belongs in the UI layer versus the business logic layer?

Provider taught me the basics of separating concerns. Riverpod taught me that testability isn’t optional — it’s a design constraint. BLoC taught me that explicit contracts, even when they feel heavy, save you from the kind of bugs that only surface at 2 AM when your app has real users.

If you’re starting a new Flutter project today, I’d probably point you toward Riverpod. But if you told me you chose Provider because your team is small and you want to ship fast? I wouldn’t argue. And if your enterprise architect mandates BLoC? You’ll be fine. Better than fine, probably — you’ll have guardrails that pay for themselves over time.

All three are production-ready. All three have active communities. All three solve the same fundamental problem: keeping your UI in sync with your data without descending into spaghetti. Pick the one that matches your team’s size, your testing appetite, and your tolerance for boilerplate. Then commit to it across your app for consistency.

And if you end up rewriting the state layer a year from now? Don’t be embarrassed. That’s just learning. That’s the job.

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.