setState works fine when your Flutter app has three screens. When it has thirty, things change. Shared user sessions, paginated lists, real-time socket data, and concurrent API calls turn setState into an obstacle. You end up passing callbacks five widgets deep. Your Provider notifiers accumulate business logic. Repository methods get called from inside build(). The app runs, but nobody on the team can easily explain what triggered a UI rebuild.
That's the state management inflection point. Teams either adopt a disciplined architecture at that moment, or spend the next 18 months debugging race conditions and unintended rebuilds.
The BLoC (Business Logic Component) pattern is where serious teams land. Google's own Flutter team uses it internally. Nubank, one of the world's largest digital banks with 90 million customers, built its app on it. BMW runs it in production. The flutter_bloc package has logged over 1.4 million downloads on pub.dev. That's not hype. That's validation at scale.
This guide covers how BLoC works, how to implement it with modern Dart 3.x features, and why its predictability matters when you're shipping over-the-air patches with tools like Shorebird.
Core Concepts: Events, States, and Streams
Before getting into code, it helps to understand the three moving parts.
- An Event is an intention. The user taps "Fetch Weather," and your app dispatches an event. Events don't carry logic. They carry data. They say "something happened" and pass along whatever context is needed to handle it.
- A State is a snapshot of what the UI should show. Loading, success, failure, plus any data needed to render.
- The BLoC receives events, executes business logic (validation, API calls, caching), and emits new states through a Dart
Stream. Widgets listen and rebuild when state changes.
Dart 3 Makes BLoC Better
Dart 3 gives you sealed classes and exhaustive switch expressions. With a sealed base state, the compiler knows every possible subtype, so your UI switch becomes a compile-time guarantee. Add a new state and forget to handle it, and the build fails.
That's one of the biggest practical improvements BLoC codebases have seen in years.
Building a Weather App with BLoC
Let's see how this works in practice by building a weather app.
Start by creating a new Flutter project:
flutter create my_new_app
Alternatively, if you want over-the-air update support from day one, install the Shorebird CLI and run:
shorebird create my_new_app
Setting Up: Packages
Add the following to your pubspec.yaml:
dependencies:
flutter_bloc: ^9.1.0
equatable: ^2.0.5
http: ^1.2.0
-
flutter_blocwires streams into the widget tree withBlocProviderandBlocBuilder. -
equatablegives your events, states, and models value equality. -
httpis used by the repository to call Open-Meteo.
Step-by-Step Implementation: Weather Fetch with Refresh
This implementation covers two user actions.
- Fetch shows a spinner and replaces the current UI state.
- Refresh updates silently, so you don't flash a loading indicator over existing data.
Step 1: Model the Domain
A small, UI-friendly model keeps state rendering predictable. It also decouples your UI from the raw API response shape.
// lib/features/weather/models/weather.dart
import 'package:equatable/equatable.dart';
/// A minimal, UI-friendly weather model for the BLoC tutorial.
///
/// Notes
/// - `tempC` is Celsius to keep formatting predictable in the UI.
/// - `conditionCode` is the raw weather code from the API (useful for icons).
/// - `fetchedAt` helps you show "updated X min ago" and supports refresh logic.
final class Weather extends Equatable {
final String city;
final double tempC;
final int conditionCode;
final String condition;
final DateTime fetchedAt;
const Weather({
required this.city,
required this.tempC,
required this.conditionCode,
required this.condition,
required this.fetchedAt,
});
/// Open-Meteo returns current conditions with numeric weather codes.
/// We fetch the human-readable condition name ourselves.
factory Weather.fromOpenMeteo({
required String city,
required Map<String, dynamic> json,
}) {
final current = (json['current'] as Map?)?.cast<String, dynamic>();
if (current == null) {
throw const FormatException('Missing "current" in weather response.');
}
final temp = current['temperature_2m'];
final code = current['weather_code'];
final time = current['time'];
if (temp == null || code == null || time == null) {
throw const FormatException('Weather response missing required fields.');
}
final fetchedAt = DateTime.tryParse(time.toString());
if (fetchedAt == null) {
throw const FormatException('Invalid "time" in weather response.');
}
final codeInt = (code as num).toInt();
return Weather(
city: city,
tempC: (temp as num).toDouble(),
conditionCode: codeInt,
condition: WeatherCondition.fromCode(codeInt).label,
fetchedAt: fetchedAt.toLocal(),
);
}
@override
List<Object> get props => [city, tempC, conditionCode, condition, fetchedAt];
}
/// Weather condition mapping for Open-Meteo weather codes.
/// Source: Open-Meteo weather codes list.
enum WeatherCondition {
clear('Clear'),
mainlyClear('Mainly clear'),
partlyCloudy('Partly cloudy'),
overcast('Overcast'),
fog('Fog'),
depositingRimeFog('Depositing rime fog'),
drizzleLight('Light drizzle'),
drizzleModerate('Moderate drizzle'),
drizzleDense('Dense drizzle'),
freezingDrizzleLight('Light freezing drizzle'),
freezingDrizzleDense('Dense freezing drizzle'),
rainSlight('Slight rain'),
rainModerate('Moderate rain'),
rainHeavy('Heavy rain'),
freezingRainLight('Light freezing rain'),
freezingRainHeavy('Heavy freezing rain'),
snowSlight('Slight snow'),
snowModerate('Moderate snow'),
snowHeavy('Heavy snow'),
snowGrains('Snow grains'),
rainShowersSlight('Slight rain showers'),
rainShowersModerate('Moderate rain showers'),
rainShowersViolent('Violent rain showers'),
snowShowersSlight('Slight snow showers'),
snowShowersHeavy('Heavy snow showers'),
thunderstormSlight('Thunderstorm'),
thunderstormModerate('Thunderstorm'),
thunderstormSlightHail('Thunderstorm with slight hail'),
thunderstormHeavyHail('Thunderstorm with heavy hail'),
unknown('Unknown');
final String label;
const WeatherCondition(this.label);
static WeatherCondition fromCode(int code) => switch (code) {
0 => WeatherCondition.clear,
1 => WeatherCondition.mainlyClear,
2 => WeatherCondition.partlyCloudy,
3 => WeatherCondition.overcast,
45 => WeatherCondition.fog,
48 => WeatherCondition.depositingRimeFog,
51 => WeatherCondition.drizzleLight,
53 => WeatherCondition.drizzleModerate,
55 => WeatherCondition.drizzleDense,
56 => WeatherCondition.freezingDrizzleLight,
57 => WeatherCondition.freezingDrizzleDense,
61 => WeatherCondition.rainSlight,
63 => WeatherCondition.rainModerate,
65 => WeatherCondition.rainHeavy,
66 => WeatherCondition.freezingRainLight,
67 => WeatherCondition.freezingRainHeavy,
71 => WeatherCondition.snowSlight,
73 => WeatherCondition.snowModerate,
75 => WeatherCondition.snowHeavy,
77 => WeatherCondition.snowGrains,
80 => WeatherCondition.rainShowersSlight,
81 => WeatherCondition.rainShowersModerate,
82 => WeatherCondition.rainShowersViolent,
85 => WeatherCondition.snowShowersSlight,
86 => WeatherCondition.snowShowersHeavy,
95 => WeatherCondition.thunderstormSlight,
96 => WeatherCondition.thunderstormSlightHail,
99 => WeatherCondition.thunderstormHeavyHail,
_ => WeatherCondition.unknown,
};
}
Equatable is key here. Identical values compare equal, which prevents subtle UI churn from unnecessary rebuilds.
Step 2: Build a Repository That Owns HTTP and Parsing
The repository has one job: take a city string and return a Weather model, or throw a meaningful error.
It makes two network calls:
- Geocode the city name to coordinates
- Fetch current conditions for those coordinates
// lib/features/weather/repositories/weather_repository.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../models/weather.dart';
/// Repository that:
/// 1) Geocodes a city name -> lat/long
/// 2) Fetches current weather for lat/long
///
/// Uses Open-Meteo:
/// - Geocoding: https://geocoding-api.open-meteo.com/v1/search
/// - Forecast: https://api.open-meteo.com/v1/forecast
class WeatherRepository {
final http.Client _client;
WeatherRepository({http.Client? client}) : _client = client ?? http.Client();
Future<Weather> fetchWeather(String city) async {
final normalized = city.trim();
if (normalized.isEmpty) {
throw const FormatException('City cannot be empty.');
}
final coords = await _geocodeCity(normalized);
final json = await _fetchCurrentWeather(coords.latitude, coords.longitude);
return Weather.fromOpenMeteo(city: coords.displayName, json: json);
}
Future<_GeoResult> _geocodeCity(String city) async {
final uri = Uri.https(
'geocoding-api.open-meteo.com',
'/v1/search',
<String, String>{
'name': city,
'count': '1',
'language': 'en',
'format': 'json',
},
);
final res = await _client.get(uri, headers: _headers());
if (res.statusCode != 200) {
throw HttpException('Geocoding failed (HTTP ${res.statusCode}).');
}
final body = jsonDecode(res.body);
if (body is! Map<String, dynamic>) {
throw const FormatException('Invalid geocoding response.');
}
final results = body['results'];
if (results is! List || results.isEmpty) {
throw StateError('No results found for "$city".');
}
final first = results.first;
if (first is! Map) {
throw const FormatException('Invalid geocoding result.');
}
final lat = first['latitude'];
final lon = first['longitude'];
final name = first['name'];
if (lat == null || lon == null || name == null) {
throw const FormatException('Geocoding result missing fields.');
}
final admin1 = first['admin1'];
final country = first['country'];
final displayName = [
name.toString(),
if (admin1 != null) admin1.toString(),
if (country != null) country.toString(),
].join(', ');
return _GeoResult(
latitude: (lat as num).toDouble(),
longitude: (lon as num).toDouble(),
displayName: displayName,
);
}
Future<Map<String, dynamic>> _fetchCurrentWeather(
double latitude,
double longitude,
) async {
final uri =
Uri.https('api.open-meteo.com', '/v1/forecast', <String, String>{
'latitude': latitude.toString(),
'longitude': longitude.toString(),
'current': 'temperature_2m,weather_code',
'temperature_unit': 'celsius',
'timezone': 'auto',
});
final res = await _client.get(uri, headers: _headers());
if (res.statusCode != 200) {
throw HttpException('Weather fetch failed (HTTP ${res.statusCode}).');
}
final body = jsonDecode(res.body);
if (body is! Map<String, dynamic>) {
throw const FormatException('Invalid weather response.');
}
return body;
}
Map<String, String> _headers() => const {'Accept': 'application/json'};
}
final class _GeoResult {
final double latitude;
final double longitude;
final String displayName;
const _GeoResult({
required this.latitude,
required this.longitude,
required this.displayName,
});
}
This separation pays off later. If you swap Open-Meteo for a paid provider, add caching, or move from REST to GraphQL, your BLoC and UI code stays unchanged. The repository absorbs the change.
Step 3: Define Events
Define two events, both carrying the city string. That keeps the public BLoC API small and makes the event history readable in logs.
// lib/features/weather/bloc/weather_event.dart
part of 'weather_bloc.dart';
sealed class WeatherEvent extends Equatable {
const WeatherEvent();
@override
List<Object> get props => [];
}
final class WeatherFetchRequested extends WeatherEvent {
final String city;
const WeatherFetchRequested(this.city);
@override
List<Object> get props => [city];
}
final class WeatherRefreshRequested extends WeatherEvent {
final String city;
const WeatherRefreshRequested(this.city);
@override
List<Object> get props => [city];
}
Step 4: Define States
This is the classic "initial, loading, success, failure" set. It's predictable, testable, and maps cleanly to UI.
// lib/features/weather/bloc/weather_state.dart
part of 'weather_bloc.dart';
sealed class WeatherState extends Equatable {
const WeatherState();
@override
List<Object> get props => [];
}
final class WeatherInitial extends WeatherState {
const WeatherInitial();
}
final class WeatherLoading extends WeatherState {
const WeatherLoading();
}
final class WeatherSuccess extends WeatherState {
final Weather weather;
const WeatherSuccess(this.weather);
@override
List<Object> get props => [weather];
}
final class WeatherFailure extends WeatherState {
final String message;
const WeatherFailure(this.message);
@override
List<Object> get props => [message];
}
Step 5: Implement the BLoC
This BLoC uses the modern on<Event>(handler) style. Fetch shows a loading state. Refresh tries to update without forcing a spinner, so the UI stays stable when the user is already looking at data.
// lib/features/weather/bloc/weather_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../models/weather.dart';
import '../repositories/weather_repository.dart';
part 'weather_event.dart';
part 'weather_state.dart';
class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
final WeatherRepository _repository;
WeatherBloc({required WeatherRepository repository})
: _repository = repository,
super(const WeatherInitial()) {
on<WeatherFetchRequested>(_onFetchRequested);
on<WeatherRefreshRequested>(_onRefreshRequested);
}
Future<void> _onFetchRequested(
WeatherFetchRequested event,
Emitter<WeatherState> emit,
) async {
emit(const WeatherLoading());
try {
final weather = await _repository.fetchWeather(event.city);
emit(WeatherSuccess(weather));
} catch (e) {
emit(WeatherFailure(e.toString()));
}
}
Future<void> _onRefreshRequested(
WeatherRefreshRequested event,
Emitter<WeatherState> emit,
) async {
// Refresh silently: don't replace existing data with a spinner
try {
final weather = await _repository.fetchWeather(event.city);
emit(WeatherSuccess(weather));
} catch (e) {
emit(WeatherFailure(e.toString()));
}
}
}
This code emits
WeatherFailureon refresh errors. In some apps you might keep the previousWeatherSuccessstate and show a non-blocking toast instead. That's a product decision, not an architecture limitation.
Step 6: Wire It Into the UI
This tutorial uses a two-widget split.
-
WeatherPagesets up the feature scope and dependency injection. -
WeatherViewowns theTextEditingControllerand renders states.
WeatherPage
Notice what it does not do. It does not construct the repository. It reads it from the tree, then constructs the BLoC with it.
// lib/features/weather/view/weather_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/weather_bloc.dart';
import '../repositories/weather_repository.dart';
import 'weather_view.dart';
class WeatherPage extends StatelessWidget {
const WeatherPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => WeatherBloc(repository: context.read<WeatherRepository>()),
child: const WeatherView(),
);
}
}
That small pattern scales well. Your feature doesn't need to care whether the repository is real, cached, mocked, or decorated for analytics.
WeatherView
This view uses Dart 3 pattern matching in the UI. Each state maps to a clear piece of output.
// lib/features/weather/view/weather_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/weather_bloc.dart';
class WeatherView extends StatefulWidget {
const WeatherView({super.key});
@override
State<WeatherView> createState() => _WeatherViewState();
}
class _WeatherViewState extends State<WeatherView> {
final _controller = TextEditingController(text: 'London');
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Weather BLoC')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'City',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () {
context.read<WeatherBloc>().add(
WeatherFetchRequested(_controller.text),
);
},
child: const Text('Fetch Weather'),
),
const SizedBox(height: 24),
BlocBuilder<WeatherBloc, WeatherState>(
builder: (context, state) {
return switch (state) {
WeatherInitial() => const Text(
'Enter a city and fetch weather',
),
WeatherLoading() => const CircularProgressIndicator(),
WeatherSuccess(:final weather) => Column(
children: [
Text(
weather.city,
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
'${weather.tempC.toStringAsFixed(1)} °C',
style: Theme.of(context).textTheme.displaySmall,
),
Text(weather.condition),
],
),
WeatherFailure(:final message) => Text(
message,
style: const TextStyle(color: Colors.red),
),
};
},
),
],
),
),
);
}
}
If you want a refresh button in the UI, you already have the event. Add an IconButton in the AppBar that dispatches WeatherRefreshRequested(_controller.text) and you'll get the silent refresh behavior from the BLoC.
Step 7: Compose the App in main.dart
This app uses RepositoryProvider at the root so any feature can read the repository. It also registers a global BlocObserver so state transitions show up in logs.
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'features/weather/repositories/weather_repository.dart';
import 'features/weather/view/weather_page.dart';
void main() {
Bloc.observer = AppBlocObserver();
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return RepositoryProvider(
create: (_) => WeatherRepository(),
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Weather BLoC Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const WeatherPage(),
),
);
}
}
/// Optional but highly recommended in real apps.
/// Logs all BLoC state transitions globally.
class AppBlocObserver extends BlocObserver {
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
debugPrint(
'[${bloc.runtimeType}] '
'${change.currentState.runtimeType} -> '
'${change.nextState.runtimeType}',
);
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
debugPrint('[${bloc.runtimeType}] ERROR: $error');
super.onError(bloc, error, stackTrace);
}
}
Run it with flutter run and watch it in action:
Two things worth noting about this setup:
- You can replace
WeatherRepository()with a mock in tests by swapping the provider. - When a bug report arrives, transition logs tell you exactly what happened without guessing.
Why Enterprise Teams Choose BLoC
BLoC isn't popular because it's trendy. It sticks because it turns app behavior into something you can reason about under pressure. When the codebase grows, the team grows, and production issues show up at 2 AM, having one predictable place where "what happened" becomes "what state did we emit, and why" is the difference between a fast fix and a long debugging session.
That said, the benefits go beyond debugging:
-
Testability without an emulator.
WeatherBlochas no dependency on Flutter rendering, so you can test it with standard unit tests. Thebloc_testpackage makes those tests read like specs. -
Traceability with BlocObserver. A
BlocObservergives you a global lens into your app's behavior. When state transitions are visible, debugging becomes less like archaeology. - Separation of concerns at team scale. The UI consumes states. It doesn't fetch or parse data. The repository handles IO and parsing. The BLoC handles orchestration. That division is what keeps larger teams from stepping on each other.
BLoC + Shorebird: Safer OTA Updates
Shorebird gives Flutter teams code push, so critical fixes can ship without waiting on store review. That only stays safe if the code you're patching is predictable.
BLoC helps here by concentrating behavior in small, isolated units. If the weather refresh behavior is wrong, the fix is likely in WeatherBloc or WeatherRepository, not scattered across widget lifecycle methods. Smaller change surface, smaller blast radius.
Shorebird solves delivery. BLoC reduces uncertainty.
Best Practices and Common Pitfalls
A few things I'd recommend keeping in mind when working with BLoC:
- Keep navigation out of BLoCs. Emit a state and handle navigation in the UI layer.
- Prefer immutable state objects. Your states already use
finalfields andconstconstructors. Keep that discipline. - Keep each BLoC single-purpose. If a BLoC starts owning unrelated domains, it becomes a bottleneck.
- Use
Cubitwhen you don't need an event stream. For many UI-only toggles, that simplicity is worth it.
Final Thoughts
BLoC asks for more structure up front. The payoff is clarity under pressure. When async calls overlap, when screens multiply, when hotfixes need to ship fast, a predictable "event in, state out" architecture is what keeps a Flutter app maintainable.
If you're building something that will grow beyond one developer and one quarter, BLoC is worth the investment. Once it's in place, a tool like Shorebird makes delivery match the architecture: fast updates without crossing your fingers.
I'm curious how others have approached this. If you've used BLoC in production, what's the one structural decision you wish you'd made earlier? Drop a comment below or share your experience. I read them all.

Top comments (0)