Persist Bloc State in Flutter – Hydrated Bloc Tutorial

4  comments

Users expect your apps to be easy and quick to use and they surely don't want to be slowed down by having to reenter some data every time they launch the app. Think about it - would you like it if the browser on your phone didn't remember the last website you opened?

That's why good apps persist their state to local storage. Sure, you can handle persistence in many ways, however, if you're using the flutter_bloc library, going the hydrated_bloc route is the best choice you can make.

Project setup

Whether you're following along or not, we need to add few dependencies to the pubspec.yaml file. Apart from the obvious packages, we will also use json_serializable to simplify converting the state to/from JSON for the hydrated_bloc to work with.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  hydrated_bloc: ^0.4.0
  flutter_bloc: ^0.21.0
  equatable: ^0.5.1
  json_annotation: ^3.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner:
  json_serializable: ^3.2.2

In this tutorial, we will build a fake weather app which remembers the lastly fetched weather across app restarts. The starter project already contains the code needed to make this app function, we will just expand it with hydrated_bloc. For an overview of the already present code, either see this tutorial which explains Bloc in-depth, or watch the accompanying video.

Serializing the State

If we want to persist the state as JSON, we surely need to have a way of serializing it. Taking a look at the WeatherStates will reveal what the next steps will be.

weather_state.dart

import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';

import '../model/weather.dart';

@immutable
abstract class WeatherState extends Equatable {
  WeatherState([List props = const []]) : super(props);
}

class WeatherInitial extends WeatherState {}

class WeatherLoading extends WeatherState {}

// Only this state contains data which has to be serialized
class WeatherLoaded extends WeatherState {
  final Weather weather;

  WeatherLoaded(this.weather) : super([weather]);
}

Now we have two options - either serialize the whole WeatherLoaded state or only the Weather model. Data operations, including serialization, logically belong to the model class - we will serialize only the Weather to keep the code clean.

Currently, it's only a simple class holding two fields. Using json_serializable we can effortlessly include the toJson and fromJson methods, so that the final version of the class will look like this.

weather.dart

import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:json_annotation/json_annotation.dart';

part 'weather.g.dart';

@JsonSerializable()
class Weather extends Equatable {
  final String cityName;
  final double temperature;

  Weather({
    @required this.cityName,
    @required this.temperature,
  }) : super([cityName, temperature]);

  factory Weather.fromJson(Map<String, dynamic> json) =>
      _$WeatherFromJson(json);
  Map<String, dynamic> toJson() => _$WeatherToJson(this);
}

As usual, whenever we do something where code generation is needed (in this case, generating the weather.g.dart file as signified by the part directive), we run everyone's favorite command:

flutter packages pub run build_runner watch

Hydrating the Bloc

Regular Blocs always extend the Bloc class. Persisting state of the Bloc requires a different super class - HydratedBloc. Of course, our work doesn't end there. Let's first talk about how and when the state is persisted and restored.

Restoration of the Bloc's state happens when the Bloc is first created. This makes sense. You wouldn't want the Bloc to restore its previous state in the middle of running the app, after all. For this reason we will modify the initialState property to not always return the WeatherInitial state, but to try getting the state from the storage.

weather_bloc.dart

import 'dart:async';
import 'dart:math';

import 'package:hydrated_bloc/hydrated_bloc.dart';

import './bloc.dart';
import '../model/weather.dart';

class WeatherBloc extends HydratedBloc<WeatherEvent, WeatherState> {
  @override
  WeatherState get initialState {
    // Super class (HydratedBloc) will try to get the lastly stored state.
    // If there isn't a stored state (null), return a default WeatherInitial.
    return super.initialState ?? WeatherInitial();
  }
  ...
}

Of course, the HydratedBloc super class has no clue how to (de)serialize our WeatherState. It's our job to override fromJson and toJson methods on the WeatherBloc which will be called by the library.

JSON Conversion

Serializing to JSON is very easy with our previous setup. We just have to make sure the state being serialized is WeatherLoaded, as the other ones cannot be serialized. Otherwise, we will return null.

Returning null will leave the stored state as it is. This means that when the user exits the app before a new weather could be loaded, he will be presented with the previous weather upon launching the app.

weather_bloc.dart

...
@override
Map<String, dynamic> toJson(WeatherState state) {
  if (state is WeatherLoaded) {
    return state.weather.toJson();
  } else {
    return null;
  }
}
...

With deserialization, it's important to first realize that it will be initiated from the initialState property, more precisely from the super class call. Over there we expect the deserialized state to be null when there's nothing in the storage, in which case we return WeatherInitial.

return super.initialState ?? WeatherInitial();

Therefore, from the fromJson method, we want to return a WeatherLoaded state or null whenever something goes wrong (no stored data, improper data, ...)

weather_bloc.dart

...
@override
WeatherState fromJson(Map<String, dynamic> json) {
  try {
    final weather = Weather.fromJson(json);
    return WeatherLoaded(weather);
  } catch (_) {
    return null;
  }
}
...
Returning null when some JSON parsing exception occurs will make the Bloc output the default WeatherInitial state.

Making It Happen

The final step in persisting Bloc state is to set a special kind of a BlocDelegate on the BlocSupervisor. Depending on your previous experience with the Bloc package, these words may or may not be clear to you...

In essence, the BlocSupervisor is a singleton with which every single Bloc communicates whenever there's a new event dispatchednew state emitted or when an error happens. Actually, the BlocSupervisor doesn't do anything by itself, instead, it delegates all its work to a BlocDelegate.

Usually, you'd subclass the BlocDelegate yourself and do some logging in there. The hydrated_bloc package comes with a ready-made HydratedBlocDelegate class which, whenever it gets notified about a new state, calls the toJson method on the Bloc in question and persists the state into storage.

Just so you have an idea of what's happening under the hood, the code responsible for this state persistence looks like this:

hydrated_bloc_delegate.dart

...
// Code inside the hydrated_bloc package
@override
void onTransition(Bloc bloc, Transition transition) {
  super.onTransition(bloc, transition);
  final dynamic state = transition.nextState;
  if (bloc is HydratedBloc) {
    final stateJson = bloc.toJson(state);
    if (stateJson != null) {
      storage.write(
        bloc.runtimeType.toString(),
        json.encode(stateJson),
      );
    }
  }
}
...

Utilizing the HydratedBlocDelegate is as simple as setting it up in the main method of the app.

main.dart

void main() async {
  BlocSupervisor.delegate = await HydratedBlocDelegate.build();
  runApp(MyApp());
}

When you now run the app, search for some fake weather and then do a hot restart or a complete restart of the app, your searched-for city and its weather will not be lost.

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter Developer at LeanCode and a developer educator, he is set on helping other people succeed in their Flutter app development career.

You may also like

Flutter UI Testing with Patrol

Flutter UI Testing with Patrol
  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >