skip to content

With hydrated_bloc, how do you persist a reading-progress Cubit across app restarts using HydratedCubit, fromJson, toJson and HydratedStorage?

level: middleimportance: should knowfreq 25%

answer

  1. storage before the first cubit
  2. HydratedStorageDirectory, web variant
  3. restored synchronously in the constructor
  4. every change is written
  5. id separates instances

basics

~10 s

Await HydratedStorage.build and assign it to HydratedBloc.storage before runApp, extend HydratedCubit, and implement fromJson and toJson. The saved state is read synchronously in the constructor, and every later change is written back.

solid answer

~40 s

In `main`, call `WidgetsFlutterBinding.ensureInitialized()`, then `HydratedBloc.storage = await HydratedStorage.build(storageDirectory: ...)` - `HydratedStorageDirectory.web` on the web, `HydratedStorageDirectory(path)` elsewhere. `ReadingProgressCubit extends HydratedCubit<ReadingProgress>` implements `fromJson(Map<String, dynamic>)` returning the state (or `null` to fall back to the initial state) and `toJson(state)` returning a map (or `null` to skip saving that state). The constructor calls `hydrate()`, which reads the stored map **synchronously**, so the first `state` the UI sees is already restored. After that, hydrated_bloc's `onChange` writes every new state. The storage key is `storagePrefix + id`; override `id` (for example with the book id) when several instances of one class must keep separate state, and use `clear()` to delete the cached value without changing the current state.

code

dart · 35 lines
dart
import 'package:equatable/equatable.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';

class ReadingProgress extends Equatable {
  const ReadingProgress({required this.chapter, required this.offset});

  final int chapter;
  final double offset;

  @override
  List<Object?> get props => [chapter, offset];
}

class ReadingProgressCubit extends HydratedCubit<ReadingProgress> {
  ReadingProgressCubit(this.bookId)
      : super(const ReadingProgress(chapter: 0, offset: 0));

  final String bookId;

  @override
  String get id => bookId;

  void moveTo(int chapter, double offset) =>
      emit(ReadingProgress(chapter: chapter, offset: offset));

  @override
  ReadingProgress? fromJson(Map<String, dynamic> json) => ReadingProgress(
        chapter: json['chapter'] as int,
        offset: (json['offset'] as num).toDouble(),
      );

  @override
  Map<String, dynamic>? toJson(ReadingProgress state) =>
      {'chapter': state.chapter, 'offset': state.offset};
}

go deeper

for a junior

Recall the three pieces: HydratedStorage assigned in main, a HydratedCubit subclass, and the fromJson and toJson pair.

for a middle

Explain synchronous restore in the constructor, saving through onChange, the storagePrefix plus id key, and when toJson or fromJson return null.

for a senior

Decide what state deserves hydration, keep keys stable, and protect saved data from failing serialisation or unexpected shapes.

for a principal

Weigh automatic hydration against an explicit repository-backed cache for data that needs versioning, syncing or encryption policies.

## What hydrated_bloc adds **hydrated_bloc** (version 11, on bloc 9) extends a bloc or cubit with automatic persistence: the last state is saved as it changes and restored when the instance is created again, including after a full app restart. You opt in by extending **`HydratedCubit<State>`** or **`HydratedBloc<Event, State>`**, or by mixing `HydratedMixin` into an existing class and calling `hydrate()` in its constructor body. ## Step 1: storage, before any hydrated instance Persistence goes through a `Storage` - normally **`HydratedStorage`**, which is backed by Hive (the community edition, `hive_ce`, since hydrated_bloc 10). It must exist before the first hydrated cubit is constructed: ```dart Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); HydratedBloc.storage = await HydratedStorage.build( storageDirectory: kIsWeb ? HydratedStorageDirectory.web : HydratedStorageDirectory( (await getApplicationSupportDirectory()).path, ), ); runApp(const ReaderApp()); } ``` Points to remember: - `HydratedBloc.storage` is a static that both `HydratedBloc` and `HydratedCubit` read. If it was never set, construction throws `StorageNotFound`. - `storageDirectory` is required and is a `HydratedStorageDirectory`, not a `dart:io` `Directory` - the change that made the package compile to WebAssembly in version 10. - `HydratedStorage.build` accepts an optional `encryptionCipher` (for example `HydratedAesCipher`). - Since version 10 a single instance can use a different storage through the constructor's named `storage:` parameter; version 11 made it named for `HydratedCubit` too. ## Step 2: the cubit 1. Extend `HydratedCubit<ReadingProgress>` and pass the initial state to `super`. 2. Implement **`toJson(state)`**: return a `Map<String, dynamic>` of JSON-compatible values, or `null` to leave that state unsaved. 3. Implement **`fromJson(json)`**: rebuild the state from the map, or return `null` to fall back to the initial state. 4. Emit as usual; persistence needs no extra calls. ## How restore and save actually run - **Restore is synchronous.** The constructor calls `hydrate()`, which calls `storage.read(key)` - a synchronous read from the already-opened box - and runs `fromJson`. There is no loading state and no extra emission: `state` is simply the restored value from the first read. - **Save happens in `onChange`.** hydrated_bloc overrides `onChange`; after `super.onChange`, it runs `toJson` and writes the map asynchronously. A custom `onChange` override must call `super.onChange`, or saving stops. - **Keys.** The storage key is `storageToken`, which is `storagePrefix` plus `id`. `storagePrefix` defaults to the class's `runtimeType` name; `id` defaults to an empty string. | Member | Default | Override when | |---|---|---| | `id` | `''` | several instances of one class need separate saved state | | `storagePrefix` | `runtimeType.toString()` | the key must survive renames, obfuscation or debug/release differences | | `toJson` returns `null` | - | a particular state should not be saved | | `clear()` | - | the cache must be wiped; the current state is left as it is | ## One cubit per book A reader with one progress cubit per book overrides `id` to return the book id, so `ReadingProgressCubit` for two books writes to two keys. Without it, every instance shares one key and the last book opened overwrites the others. ## Common mistakes - Creating a hydrated cubit before `HydratedBloc.storage` is assigned. - Returning non-JSON values (a `DateTime`, a custom object without `toJson`) from `toJson`, which fails with `HydratedUnsupportedError`. - Expecting an async "restored" state to arrive after construction.

  • Why does a HydratedCubit not emit a separate 'restored' state after construction?
    `hydrate()` runs inside the constructor and reads storage synchronously, because `HydratedStorage.build` already opened the box. The restored value becomes `state` before anyone can listen, so there is nothing to emit - widgets read the restored value on their first build.
  • What does clear() do to a running HydratedCubit?
    It deletes the cached entry for the cubit's storage key and leaves the current state untouched. The next emitted state is written again, so clearing is usually paired with emitting the initial state if the UI should reset too.

saying these in an interview costs you the question

  • Creates hydrated cubits before assigning HydratedBloc.storage.
  • Expects the restored state to arrive asynchronously as an extra emission.
  • Passes a dart:io Directory to HydratedStorage.build in hydrated_bloc 11.
  • Shares one storage key across instances for different books by leaving id empty.
  • Returns DateTime objects directly from toJson.