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:
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.
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;
}
}
...
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 dispatched, new 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.
Hi how are you.
it does not return the previous state to me when it enters the cacth.
bullshit tutorial i’ve ever read and watch!
Your point of view caught my eye and was very interesting. Thanks. I have a question for you.
I don’t think the title of your article matches the content lol. Just kidding, mainly because I had some doubts after reading the article.