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?
answer
- fromJson throws or returns null
- onError plus onHydrationError
- overwrite is the default
- retain freezes persistence
- version field; stable storagePrefix
basics
~20 sIf 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 sIn 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@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
Remember that fromJson must handle whatever older versions of the app saved, or the saved state is lost.
Explain the difference between fromJson returning null and throwing, and what the default overwrite behaviour does to the stored data.
Plan migrations with versioned payloads, pinned storage prefixes and tests using real old payloads, and choose overwrite or retain deliberately.
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.