Mobile State Management: Provider vs Riverpod vs BLoC

State management is the most debated topic in Flutter development. There are dozens of approaches, but three dominate production apps: Provider, Riverpod, and BLoC. Instead of abstract comparisons, this article builds the same weather lookup feature using all three — so you can see the real differences in code structure, testability, and complexity.

The Shared Data Layer

All three implementations share the same model and repository. This keeps the comparison fair:

// 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));
  }
}

Approach 1: Provider

Provider is the simplest option. It wraps Flutter’s InheritedWidget in a developer-friendly API. State lives in a ChangeNotifier:

ADVERTISEMENT
// 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!),
          ],
        ),
      ),
    );
  }
}

Pros: Minimal boilerplate, easy to learn, officially recommended by the Flutter team. Cons: No compile-time safety for provider lookups, runtime errors when a provider is missing from the widget tree.

Approach 2: Riverpod

Riverpod fixes Provider’s limitations with compile-time safety, no BuildContext dependency, and better testability:

// 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)),
            ),
          ],
        ),
      ),
    );
  }
}

Pros: Compile-time safety, no BuildContext needed for provider access, built-in AsyncValue for loading/error/data states, excellent for testing with ProviderContainer overrides. Cons: Steeper learning curve, more concepts to understand (Provider, Notifier, AsyncNotifier, etc.).

Approach 3: BLoC

BLoC (Business Logic Component) enforces a strict unidirectional data flow: Events go in, States come out. It adds the most structure but also the most boilerplate:

// 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');
              },
            ),
          ],
        ),
      ),
    );
  }
}

Pros: Very explicit data flow, easy to trace how state changes, excellent dev tools (bloc_test for unit testing, BLoC Observer for logging), scales well in large teams. Cons: Significant boilerplate (events + states + bloc classes), can be overkill for simple features.

The Shared Weather Card Widget

All three screens use this same display widget:

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}%'),
          ],
        ),
      ),
    );
  }
}

Which Should You Choose?

Provider is right for small to medium apps and teams new to Flutter. Start here. Riverpod is ideal when you need compile-time safety, extensive testing, and feature-flag-style provider overrides. It is Provider’s evolution. BLoC shines in large teams and enterprise apps where explicit event-state contracts make code reviews and onboarding easier.

All three are production-ready. Pick the one that matches your team’s size, testing needs, and tolerance for boilerplate — then commit to it across your app for consistency.

ADVERTISEMENT

Leave a Comment

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