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:
// 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.