---
title: "Advanced State Management (BLoC)"
description: "Build a production-ready weather app with the BLoC Single State pattern"
---

The [Beginner Guide](/flutter-concepts/state-management/beginner) explored the core philosophy of BLoC and how to leverage the Single State + Enum pattern to avoid screen flickering and preserve data.

Now, put these professional state management practices into action by building a fully-featured, production-ready weather app from scratch using BLoC, the repository pattern, and HTTP integrations.

This walkthrough will cover:

* Modeling domain entities with value equality (`Equatable`)
* Fetching and parsing multi-step HTTP requests in a repository
* Creating a single-state BLoC using status enums
* Implementing silent pulls-to-refresh
* Wiring the BLoC and repository dependencies into the Flutter UI
* Using `BlocObserver` to log and trace transitions

***

## Setting up a new project

To get started, create a new Flutter app by running the following command:

```
flutter create my_new_app
```

Alternatively, if you want support for over-the-air updates from day 1, you can [install the Shorebird CLI](/getting-started/) and run:

```
shorebird create my_new_app
```

### Setting up: packages

Add the following packages to your `pubspec.yaml` file:

```
dependencies:
  flutter_bloc: ^9.1.0
  equatable: ^2.0.5
  http: ^1.2.0
```

* `flutter_bloc` wires streams into the widget tree with `BlocProvider` and `BlocBuilder`.
* `equatable` gives your events, states, and models value equality.
* `http` is used by the repository to call the weather API.

***

## Step-by-step implementation

This implementation includes two user actions:

1. **Fetch** shows a spinner and replaces the UI state.
2. **Refresh** updates silently in the background, keeping the existing content visible while indicating a loading overlay.

### Step 1: model the domain

A small, UI-friendly model makes state rendering predictable. It also keeps your UI independent of 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.
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,
  });

  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.
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'),
  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,
  };
}
```

A key detail here is using `Equatable`. It prevents subtle UI churn because identical values compare equal, avoiding unnecessary rebuilds.

### Step 2: build the weather repository

The repository has one job: take a city name, search coordinates, fetch the forecast, and return a structured `Weather` model (or throw an error).

```
// 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 -> latitude/longitude
/// 2) Fetches current weather for latitude/longitude
///
/// 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,
  });
}
```

Separating this logic ensures that if you swap weather providers or add caching layers later, your BLoC and UI layers remain unaffected.

### Step 3: define events

Next, define the events. Events should name actions that happened.

```
// 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 using the single state pattern

Instead of creating separate classes for each state (which breaks data retention during refreshes), use a Single State Class with a status enum.

```
// lib/features/weather/bloc/weather_state.dart
part of 'weather_bloc.dart';

enum WeatherStatus { initial, loading, success, failure }

class WeatherState extends Equatable {
  final WeatherStatus status;
  final Weather? weather;
  final String? errorMessage;

  const WeatherState({
    this.status = WeatherStatus.initial,
    this.weather,
    this.errorMessage,
  });

  WeatherState copyWith({
    WeatherStatus? status,
    Weather? weather,
    String? errorMessage,
  }) {
    return WeatherState(
      status: status ?? this.status,
      weather: weather ?? this.weather,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }

  @override
  List<Object?> get props => [status, weather, errorMessage];
}
```

### Step 5: Implement the BLoC

Now write the business logic orchestrator, using `copyWith` to emit state modifications. Notice that during refreshes, the existing weather data is preserved.

```
// 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 WeatherState()) {
    on<WeatherFetchRequested>(_onFetchRequested);
    on<WeatherRefreshRequested>(_onRefreshRequested);
  }

  Future<void> _onFetchRequested(
    WeatherFetchRequested event,
    Emitter<WeatherState> emit,
  ) async {
    emit(state.copyWith(status: WeatherStatus.loading));
    try {
      final weather = await _repository.fetchWeather(event.city);
      emit(state.copyWith(
        status: WeatherStatus.success,
        weather: weather,
      ));
    } catch (e) {
      emit(state.copyWith(
        status: WeatherStatus.failure,
        errorMessage: e.toString(),
      ));
    }
  }

  Future<void> _onRefreshRequested(
    WeatherRefreshRequested event,
    Emitter<WeatherState> emit,
  ) async {
    // Silent Refresh: set status to loading, but do not delete existing weather data!
    emit(state.copyWith(status: WeatherStatus.loading));
    try {
      final weather = await _repository.fetchWeather(event.city);
      emit(state.copyWith(
        status: WeatherStatus.success,
        weather: weather,
      ));
    } catch (e) {
      emit(state.copyWith(
        status: WeatherStatus.failure,
        errorMessage: e.toString(),
      ));
    }
  }
}
```

> This code emits `WeatherFailure` on refresh errors. In some apps, you might keep the previous `WeatherSuccess` state and show a non-blocking toast instead. That’s a product decision, not an architecture limitation.

### Step 6: Wiring It Into the UI

This feature layout is split into:

* `WeatherPage` – Sets up the dependency injection context.
* `WeatherView` – Owns the text controllers and renders states.

**WeatherPage**

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

**WeatherView**

```
// 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) {
                // If there is cached or existing weather data, render it.
                // We keep displaying it even while a background refresh loads.
                if (state.weather != null) {
                  return Column(
                    children: [
                      Text(
                        state.weather!.city,
                        style: Theme.of(context).textTheme.headlineMedium,
                      ),
                      Text(
                        '${state.weather!.tempC.toStringAsFixed(1)} °C',
                        style: Theme.of(context).textTheme.displaySmall,
                      ),
                      Text(state.weather!.condition),

                      // Overlay an activity bar if a background refresh is in progress
                      if (state.status == WeatherStatus.loading) ...[
                        const SizedBox(height: 16),
                        const CircularProgressIndicator(),
                      ],
                    ],
                  );
                }

                // If no weather has been fetched yet, render initial/loading/failure views
                return switch (state.status) {
                  WeatherStatus.initial => const Text(
                      'Enter a city and fetch weather',
                    ),
                  WeatherStatus.loading => const CircularProgressIndicator(),
                  WeatherStatus.failure => Text(
                      state.errorMessage ?? 'An error occurred',
                      style: const TextStyle(color: Colors.red),
                    ),
                  WeatherStatus.success => const SizedBox.shrink(),
                };
              },
            ),
          ],
        ),
      ),
    );
  }
}
```

### Step 7: main entry point and observer configuration

Wire repositories and observation logic globally inside your `main.dart`.

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

class AppBlocObserver extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    debugPrint(
      '[${bloc.runtimeType}] '
      '${change.currentState.status} → '
      '${change.nextState.status}',
    );
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    debugPrint('[${bloc.runtimeType}] ERROR: $error');
    super.onError(bloc, error, stackTrace);
  }
}
```

***

## Why enterprise teams choose BLoC

BLoC isn’t popular because it is trendy; it sticks because it makes app behavior extremely predictable. When the codebase grows, and production issues happen under pressure, having one place where “what happened” maps strictly to state output reduces debugging surface area.

* **Testability without emulation**: `WeatherBloc` has no dependencies on the Flutter UI, so you can test it entirely using standard unit tests or the [`bloc_test`](https://pub.dev/packages/bloc_test) package.
* **Traceability with observers**: A custom `BlocObserver` logs all transitions globally.
* **Decoupled code ownership**: Designers work on the UI matching state variables, network developers write repositories, and logic developers write BLoCs.

***

## BLoC + Shorebird: OTA Code Push Optimization

[Shorebird](https://shorebird.dev/) enables Flutter applications to download code updates on the fly without waiting for store reviews.

The Single State pattern works hand-in-hand with Shorebird:

1. **Minimized Change blast radius**: If you need to fix state mappings or API endpoints, the code changes are isolated to standard Dart files (`WeatherBloc` or `WeatherRepository`) rather than UI layout files.
2. **Stable State Restoration**: Because the state structure is single-class and predictable, patching business logic does not break active UI memory sessions.
