skip to content

After a release changes a HydratedCubit's state shape, saved JSON from the old version no longer parses; how does hydrated_bloc 11 react, and how do you migrate safely?

level: seniorimportance: nice to knowfreq 15%

answer

  1. fromJson throws or returns null
  2. onError plus onHydrationError
  3. overwrite is the default
  4. retain freezes persistence
  5. version field; stable storagePrefix

basics

~20 s

If fromJson throws, hydrated_bloc reports it through onError, starts from the initial state and, by default, immediately overwrites the saved data. Migrate by versioning the JSON and reading old shapes in fromJson, with a stable storagePrefix.

solid answer

~40 s

In hydrated_bloc 11, `hydrate()` wraps `fromJson`. If it throws, the error goes to `onError` (and so to the `BlocObserver`), the cubit starts from its initial state, and the constructor's `onHydrationError` callback decides what happens next. The default, `HydrationErrorBehavior.overwrite`, writes the current (initial) state straight away, so the old data is gone. `HydrationErrorBehavior.retain` keeps the stored JSON but disables saving for that instance, so this session's progress is not persisted. Returning `null` from `fromJson` is a clean "discard": initial state, no error, then overwrite. To migrate safely, write a version field in `toJson`, branch on it in `fromJson` to read old shapes, fall back to `null` only for data you cannot convert, and override `storagePrefix` with a fixed string so renames and obfuscated builds keep the same key.

code

dart · 23 lines
dart
@override
ReadingProgress? fromJson(Map<String, dynamic> json) {
  switch (json['v']) {
    case 2:
      return ReadingProgress(
        chapter: json['chapter'] as int,
        offset: (json['offset'] as num).toDouble(),
      );
    case null:
      // Version 1 stored only a page number.
      final page = json['page'];
      return page is int ? ReadingProgress(chapter: 0, offset: page.toDouble()) : null;
    default:
      return null;
  }
}

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

@override
String get storagePrefix => 'ReadingProgressCubit';

go deeper

for a junior

Remember that fromJson must handle whatever older versions of the app saved, or the saved state is lost.

for a middle

Explain the difference between fromJson returning null and throwing, and what the default overwrite behaviour does to the stored data.

for a senior

Plan migrations with versioned payloads, pinned storage prefixes and tests using real old payloads, and choose overwrite or retain deliberately.

for a principal

Decide which state is safe to hydrate at all, and when schema-versioned storage owned by a repository is the better home for long-lived data.

## The problem A **HydratedCubit** stores whatever `toJson` returned in the previous version of the app. When a release changes the state class - a reading-progress cubit that used to store `{'page': 42}` now stores `{'chapter': 3, 'offset': 0.4}` - the first launch after the update feeds the **old** map to the **new** `fromJson`. What happens next is decided by hydrated_bloc, and its default is destructive. ## What hydrate() does, step by step From the hydrated_bloc 11 source, the constructor's `hydrate()` call: 1. Reads the map stored under the cubit's key. 2. Calls `fromJson`. If it returns a state, that becomes `state`. If it returns `null`, the cubit uses its initial state. 3. If `fromJson` **throws**, the error is passed to the cubit's `onError` (and on to `BlocObserver.onError`), the initial state is used, and the `onHydrationError` callback given to the constructor returns a `HydrationErrorBehavior`. 4. Unless that behaviour is `retain`, the current state is written back to storage immediately. | Outcome of `fromJson` | State after construction | Error reported | Stored data | |---|---|---|---| | returns a state | the restored state | no | rewritten with it | | returns `null` | initial state | no | overwritten with the initial state | | throws, default `overwrite` | initial state | yes, via `onError` | overwritten with the initial state | | throws, `retain` | initial state | yes, via `onError` | kept; new states are not saved by this instance | ## overwrite versus retain - **`overwrite`** (the default, returned by `defaultOnHydrationError`) accepts the loss: the app continues from scratch and saving works normally. - **`retain`** protects the old data - useful when a later build (or a fixed `fromJson`) could still read it - but the instance stops persisting: its `onChange` skips writing for the rest of its life. A reader using `retain` would lose every page turned in that session. Neither behaviour migrates anything. They only choose which loss you prefer. ## A safe migration 1. **Version the payload.** Have `toJson` write a field such as `'v': 2` from the first release that uses hydration - retrofitting it later means treating "no version" as version 1. 2. **Read every shape you ever wrote.** In `fromJson`, switch on the version and convert old shapes to the new state. 3. **Return `null` for data you truly cannot convert**, rather than throwing - it is explicit and avoids a reported error for an expected case. 4. **Keep keys stable.** The key is `storagePrefix + id`, and `storagePrefix` defaults to the class's `runtimeType` name. Renaming the class changes the key and orphans the old data; the package documentation also advises overriding `storagePrefix` when stored data must survive obfuscation, minification or differences between debug and release builds. 5. **Test with real old payloads.** A unit test that feeds a saved v1 map to the new `fromJson` catches a destructive release before users do. ## Observability Because a throwing `fromJson` goes through `onError`, a `BlocObserver` that forwards errors will see hydration failures in production. That is how you find out a migration missed a shape - provided the error is not swallowed inside `fromJson`. ## What not to do - Let `fromJson` throw on old data and rely on the default: users silently lose their saved state. - Choose `retain` without realising the instance then saves nothing. - Rename the cubit class in a refactor without pinning `storagePrefix`.

  • When is HydrationErrorBehavior.retain the right choice?
    When losing the stored data is worse than not saving for one session - for example when a hotfix with a corrected `fromJson` is expected, and the old payload must still be there for it. The cost is that this instance persists nothing until it is recreated and hydrates successfully.
  • Why can building with obfuscation change where a HydratedCubit's state is stored?
    The default `storagePrefix` is `runtimeType.toString()`, and obfuscation can change the name that returns. The storage key then differs from the one earlier builds wrote. Overriding `storagePrefix` with a fixed string keeps the key stable.

saying these in an interview costs you the question

  • Assumes a failed hydration keeps the old saved data by default.
  • Believes retain keeps the old data and still saves new states.
  • Lets fromJson throw on old payloads instead of converting or returning null.
  • Renames the cubit class without pinning storagePrefix.
  • Adds a version field only after the first breaking change shipped.