> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/architecture-state/redux-persist-state-migration.md).

# Redux Persist and State Migration

How I limit persisted Redux state, migrate versioned schemas, and stop saving until storage loads successfully

## Result

In my app, I do not serialize the entire Redux state and write it to storage after every action. I put a persistence boundary between the runtime store and data on disk:

1. Select only the fields that need to survive the next app launch.
2. Add a `databaseVersion` to the persisted payload.
3. Migrate one version at a time before hydrating the store.
4. Serialize load and save operations with a lock.
5. Skip unchanged payloads and throttle normal saves.
6. If storage has not loaded successfully, suspend saving so the initial state cannot overwrite old data.

The final flow has two explicit directions:

```
Runtime AppState
      │ select fields to persist
      ▼
PersistedAppState + databaseVersion
      │ encode
      ▼
Lock ─────────────────────────────► Storage

Storage
   │ load
   ▼
Raw JSON ─► migrate version by version ─► decode current snapshot
                                                     │
                                                     ▼
                                              hydrate AppState
```

Runtime state and persisted state are two different schemas. One direction selects data to save; the other migrates old data before hydration.

The result I want is not a claim that “Redux Persist will never lose data.” The goal is to make schema, write ordering, failure policy, and recovery decisions reviewable and testable.

This is a Shared architecture implemented in Dart. Platform-specific Keychain and Keystore behavior belongs in the Secure Storage article, so I do not add Android and iOS sections that merely repeat it.

## Problem

When I first added Redux Persist, I cared about one thing: whether the app could restore its old state after reopening. The simplest approach was to attach middleware to the store and serialize `AppState` after every action.

That worked while the store was small. As the number of features grew, however, `AppState` began to contain data with very different lifetimes:

| State type         | Example                               | Persist?                    | Reason                                                  |
| ------------------ | ------------------------------------- | --------------------------- | ------------------------------------------------------- |
| Stable preference  | Theme, locale, display preference     | Yes                         | Users expect settings to remain                         |
| Recoverable data   | Part of a session or onboarding state | Depends on the threat model | Keep only the minimum required data                     |
| Reloadable data    | API lists, detail caches              | Usually no                  | It becomes stale and enlarges the payload               |
| Temporary UI state | Loading, dialogs, animations          | No                          | It no longer has meaning in the next session            |
| Runtime errors     | Exceptions, request failures          | No                          | Errors from the previous session should not be hydrated |
| Secrets            | Access tokens, private keys           | Not in ordinary JSON        | They require a separate storage and security policy     |

Serializing the whole store means a small runtime-state refactor can accidentally change the schema on disk. A loading flag, cache, or error from the previous session can reappear as soon as the app starts.

The schema also changes over time. A field can be renamed, moved into a nested object, or replaced with a new structure. If the current model decodes the old payload directly, the app can fail before migration gets a chance to run.

A write is also more than one `save` call:

* Actions arriving close together trigger repeated serialization and platform writes.
* A slow write can overlap with the next write.
* If the latest payload is cached before a write succeeds, a retry with the same payload will be skipped.
* If an encoder returns `null` while storage treats `save(null)` as delete, a serialization error can become a deletion command.
* If loading fails but the app interprets that as “there is no data,” the initial state can overwrite old data.

I therefore do not treat persistence as “save after every action.” It is a boundary that owns its schema, migrations, concurrency, error policy, recovery, and observability.

## Solution

### Separate runtime state from the persisted snapshot

My current implementation creates a new `AppState` inside the encoder and copies only the fields that are allowed to persist. Fields that are not copied fall back to their defaults. This creates an explicit whitelist, but the schema on disk still shares a type with runtime state.

If I redesigned this boundary, I would introduce a dedicated DTO so the persistence decision is easier to see:

```dart
final class PersistedAppState {
  const PersistedAppState({
    required this.databaseVersion,
    required this.theme,
    required this.locale,
    required this.showHints,
  });

  final int databaseVersion;
  final String theme;
  final String locale;
  final bool showHints;

  factory PersistedAppState.fromJson(Map<String, dynamic> json) {
    final preferences = json['preferences'] as Map<String, dynamic>;

    return PersistedAppState(
      databaseVersion: json['databaseVersion'] as int,
      theme: preferences['theme'] as String? ?? 'system',
      locale: preferences['locale'] as String? ?? 'vi',
      showHints: preferences['showHints'] as bool? ?? true,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'databaseVersion': databaseVersion,
      'preferences': {
        'theme': theme,
        'locale': locale,
        'showHints': showHints,
      },
    };
  }
}
```

The example uses neutral preferences only. A token or private key does not belong in this DTO merely because it exists somewhere in `AppState`.

The mapper is the only place that knows both runtime state and the persisted snapshot:

```dart
PersistedAppState selectPersistedState(AppState state) {
  return PersistedAppState(
    databaseVersion: currentDatabaseVersion,
    theme: state.preferences.theme,
    locale: state.preferences.locale,
    showHints: state.preferences.showHints,
  );
}

AppState hydratePersistedState(
  AppState initial,
  PersistedAppState persisted,
) {
  return initial.copyWith(
    preferences: initial.preferences.copyWith(
      theme: persisted.theme,
      locale: persisted.locale,
      showHints: persisted.showHints,
    ),
  );
}
```

I prefer an explicit mapper over a generic deep merge of two JSON objects. A deep merge can keep obsolete fields or overwrite runtime defaults with values that are no longer valid.

A dedicated DTO adds maintenance work, but it also means:

* Refactoring runtime state does not automatically change the format on disk.
* Code review shows exactly which new fields enter the persistence boundary.
* Migrations target a smaller and more stable model.
* Loading, error, and cache state are excluded by default.

`PersistedAppState` in this section is an improvement I propose over my current whitelist. It is not a class that already exists in the reference app source.

### Put the version inside the persisted payload

I store the version at the root of the payload:

```json
{
  "databaseVersion": 2,
  "preferences": {
    "theme": "dark",
    "locale": "vi",
    "showHints": true
  }
}
```

`databaseVersion` is the version of the persisted schema, not the app version. The app version can increase without a schema change, while one release can contain several migration steps if the storage format changes.

My implementation treats an older payload with no version as V1. A fixture test must lock in this compatibility rule. Otherwise, a decoder change can remove the upgrade path for existing installations.

The reference source currently contains a V1 → V2 migration. The public example below adds a neutral V3 to demonstrate how the chain continues after more than one schema transition.

### Migrate raw JSON before decoding the current model

The current source calls `AppState.fromJson` before running migration. That works only while the new model can still decode the old payload. If an old field has moved or changed type, typed decoding can fail before migration begins.

I recommend migrating a `Map<String, dynamic>` first and creating `PersistedAppState` only afterward:

```dart
const currentDatabaseVersion = 3;

Map<String, dynamic> migrateToCurrent(
  Map<String, dynamic> input,
) {
  var json = Map<String, dynamic>.from(input);
  var version = json['databaseVersion'] as int? ?? 1;

  if (version > currentDatabaseVersion) {
    throw UnsupportedError(
      'Unsupported persisted schema version: $version',
    );
  }

  while (version < currentDatabaseVersion) {
    switch (version) {
      case 1:
        json = migrateV1ToV2(json);
        break;
      case 2:
        json = migrateV2ToV3(json);
        break;
      default:
        throw StateError('Missing migration from version $version');
    }

    version = json['databaseVersion'] as int;
  }

  return json;
}
```

Each migration knows only the immediately previous and next versions. For example, V1 kept `showHints` at the root, while V2 moves it into `preferences`:

```dart
Map<String, dynamic> migrateV1ToV2(
  Map<String, dynamic> input,
) {
  final next = Map<String, dynamic>.from(input);
  final preferences = Map<String, dynamic>.from(
    next['preferences'] as Map? ?? const {},
  );

  preferences['showHints'] = next.remove('showHints') ?? true;
  next['preferences'] = preferences;
  next['databaseVersion'] = 2;

  return next;
}
```

V2 still uses the old `darkMode` and `lightMode` values. V3 normalizes them so the runtime model does not need to keep aliases forever:

```dart
Map<String, dynamic> migrateV2ToV3(
  Map<String, dynamic> input,
) {
  final next = Map<String, dynamic>.from(input);
  final preferences = Map<String, dynamic>.from(
    next['preferences'] as Map? ?? const {},
  );

  preferences['theme'] = switch (preferences['theme']) {
    'darkMode' => 'dark',
    'lightMode' => 'light',
    final value => value,
  };
  next['preferences'] = preferences;
  next['databaseVersion'] = 3;

  return next;
}
```

I keep four migration rules:

1. Do not mutate the input map.
2. Increase the version only after the transition succeeds.
3. Do not jump from V1 directly to V4 with one large function.
4. Do not downgrade a payload created by a newer app.

The fourth rule matters when a user installs a new app version and then downgrades. The old app does not understand a future schema, so it must enter an explicit recovery policy instead of partially decoding the payload and continuing to save.

### Load, migrate, and only then hydrate the Redux store

My startup flow loads persisted state before creating the store. With a dedicated DTO, the flow can be expressed directly:

```dart
Future<AppState> restoreAppState(
  PersistStorage storage, {
  required bool isFirstInstall,
}) async {
  final bytes = await storage.load();
  if (bytes == null) {
    if (isFirstInstall) return AppState.initial();
    throw StateError('Persisted state unexpectedly missing');
  }

  final decoded = jsonDecode(utf8.decode(bytes));
  if (decoded is! Map<String, dynamic>) {
    throw const FormatException('Persisted state must be a JSON object');
  }

  final migrated = migrateToCurrent(decoded);
  final persisted = PersistedAppState.fromJson(migrated);

  return hydratePersistedState(AppState.initial(), persisted);
}
```

The order is:

```
Load bytes
   │
   ▼
Decode raw JSON
   │
   ▼
Migrate V1 → V2 → ... → current
   │
   ▼
Decode PersistedAppState
   │
   ▼
Hydrate runtime AppState
```

No step writes back to storage before the old payload is known to be valid or the recovery policy has decided what to do.

The current source has two hydration paths:

* At startup, the loaded state becomes the Redux store's `initialState`.
* When storage becomes available again, the app dispatches a hydration action that replaces state with the recovered payload.

With a dedicated DTO, I recommend explicitly hydrating persisted slices into `AppState.initial()`. Replacing the entire state is appropriate only when the payload genuinely represents the complete runtime state and all temporary fields have been reset correctly.

### Save only when the action and payload require it

Under the `redux_persist` contract, middleware can save after every action. I add three layers to reduce writes:

1. `shouldSave` excludes high-frequency actions that do not change a persisted slice.
2. Normal saves use a short throttle window.
3. Encoded bytes equal to the most recently successful payload skip the write.

A neutral policy looks like this:

```dart
bool shouldSave(dynamic action) {
  return action is! LiveValueReceived;
}

Duration throttleFor(dynamic action) {
  if (action is SessionEnded) return Duration.zero;
  return const Duration(seconds: 2);
}
```

In my source, normal saves are throttled for two seconds and logout saves immediately. This is an application-boundary decision, not a Redux rule.

Throttling reduces serialization and platform calls, but it has a trade-off: the app can be killed before the timer runs. Important boundaries need an explicit flush or immediate-save path. I do not increase the throttle to tens of seconds merely to reduce writes.

### Serialize load and save with the same lock

Saves can overlap when a platform write takes longer than the next throttle window. If each caller compares payloads outside the lock, both can observe the same stale `_lastSavedPayload`.

I put comparison, writing, and cache updates inside one lock:

```dart
Future<void> save(AppState state) async {
  final snapshot = selectPersistedState(state);
  final payload = utf8.encode(jsonEncode(snapshot.toJson()));

  await storageLock.synchronized(() async {
    if (sameBytes(payload, lastSavedPayload)) return;

    try {
      await storage.save(Uint8List.fromList(payload));
      lastSavedPayload = Uint8List.fromList(payload);
    } catch (_) {
      lastSavedPayload = null;
      rethrow;
    }
  });
}
```

The lock protects all four operations:

* Reading the last known payload.
* Comparing the new payload.
* Waiting for the platform write to complete.
* Updating the last known payload.

Load uses the same lock and records the bytes returned by storage. The first action after startup therefore does not rewrite the payload that is already there.

```
save A ─────── write A ─────── done
                                │
save B waits ───────────────────┴── compare ─► write B
```

Save B decides only after it knows what save A actually wrote to storage.

### Cache the payload only after a successful write

`lastSavedPayload` does not mean “the latest payload I attempted to write.” It means “the payload storage is known to hold.”

The cache is therefore updated only after `await storage.save(...)`. If the write fails, I clear the comparison cache so the next save with the same payload is still retried.

This small detail creates a clear difference in the failure path:

```
Wrong:
cache B ─► write B fails ─► next attempt sees B in cache ─► skip

Correct:
write B fails ─► clear cache ─► retry B next time
```

### Do not use `null` as an implicit delete command

In the storage interface I use, `save(null)` can mean delete. A serializer or transform returning `null` must therefore never reach storage.

I want the API to express two different intentions:

```dart
abstract interface class PersistStorage {
  Future<Uint8List?> load();
  Future<void> save(Uint8List payload);
  Future<void> deletePersistedState();
}
```

Only a deliberate wipe flow calls `deletePersistedState()`. A serialization failure must throw and remain observable; it must not become data deletion.

The current implementation must still follow the package interface that accepts a nullable payload, so the persistor rejects `null` before calling storage. A test confirms that an encoder returning `null` does not trigger save or delete.

### Suspend saving when load fails

This is the most important protection in the persistence boundary:

```
Load persisted state
   ├── success ───────────────► hydrate ─► persist ON
   │
   ├── missing on first install ────────► persist ON
   │
   └── exception or unexpected missing state
                  │
                  ├── use a controlled initial state
                  └── persist OFF
                           │ retry load
                           ▼
                      load succeeds
                           │
                           ├── hydrate
                           └── persist ON
```

In the current implementation:

* A storage exception puts the persistor into `loadFailed` state.
* Middleware skips every save while `loadFailed` remains set.
* A later successful load automatically enables saving again.
* A `null` payload outside the first installation is treated as unexpected and calls `suspendSaving()`.

A full decode failure also has a recovery hook that attempts to restore a minimal part of state and uses defaults for the rest. I use partial recovery only when those fields are independent and covered by dedicated tests. Recovering half a payload can produce inconsistent state, so it is not the default fallback for every schema error.

The Secure Storage article owns the reasons Keychain or Keystore may temporarily fail to return data and the platform write protection. This article owns the Redux-boundary policy: until the old state is known, new state must not be saved.

{% content-ref url="/pages/Fbl8YWIUovHWvjb7NUlz" %}
[Secure Storage: Keychain and Keystore](/flutter/my-flutter/security-observability/secure-storage-keychain-keystore.md)
{% endcontent-ref %}

### Observe failures without logging the payload

I record the phase and outcome, not raw JSON:

* Load, raw decode, migration, typed decode, hydration, or save.
* The old schema version and target version.
* A normalized exception type.
* Whether a save was skipped because it was unchanged, throttled, or suspended.
* Whether recovery succeeded or continued to fail.

Storage keys, tokens, user identifiers, and persisted payloads do not appear in logs. Even field names can expose business behavior, so public telemetry examples use neutral categories.

### Test the persistor with fake storage

I do not test only the final state. Fake storage must be able to control load failures, write failures, and a write that remains blocked.

```dart
final class FakeStorage implements StorageEngine {
  bool failLoad = false;
  bool failSave = false;
  int saveCount = 0;
  Uint8List? contents;
  Completer<void>? writeGate;

  @override
  Future<Uint8List?> load() async {
    if (failLoad) throw StateError('storage unavailable');
    return contents;
  }

  @override
  Future<void> save(Uint8List? data) async {
    saveCount++;
    if (writeGate != null) await writeGate!.future;
    if (failSave) throw StateError('write failed');
    contents = data;
  }
}
```

The existing tests in my source cover these behaviors:

* Saving the same bytes three times produces one platform write.
* Repeated no-op actions across throttle windows do not rewrite an unchanged payload.
* When write `Y` is blocked and state returns to `X`, storage still ends with `X`.
* The loaded payload is recorded, so startup does not save it again.
* Loading `null` clears the comparison cache.
* A failed write allows the same payload to be retried.
* A failed load blocks middleware saves.
* A later successful load enables saving again.
* An encoder returning `null` never reaches storage.
* Save failures go through an observation callback instead of becoming unhandled asynchronous errors.

The overlap test is the most important test for the lock:

```dart
test('queued save compares against what actually landed', () async {
  final storage = FakeStorage();
  final persistor = buildPersistor(storage);

  await persistor.save('X');
  storage.writeGate = Completer<void>();

  final writingY = persistor.save('Y');
  await pumpEventQueue();
  final queuedX = persistor.save('X');

  storage.writeGate!.complete();
  await writingY;
  await queuedX;

  expect(utf8.decode(storage.contents!), 'X');
}
```

This test checks more than the call count. Its purpose is to verify that completion order does not accidentally leave storage holding the wrong state.

### Add fixture tests for migration

The persistor already has tests for concurrency and the failure gate, but the migration service in the reference source does not yet have a dedicated fixture suite. This is a gap I want to close before treating migration as complete.

The first fixture should prove that an unversioned payload is interpreted as V1 and follows the expected chain:

```dart
test('migrates an unversioned V1 payload to V3', () {
  final v1 = <String, dynamic>{
    'showHints': false,
    'preferences': {
      'theme': 'dark',
      'locale': 'vi',
    },
  };

  final migrated = migrateToCurrent(v1);
  final preferences =
      migrated['preferences'] as Map<String, dynamic>;

  expect(migrated['databaseVersion'], 3);
  expect(preferences['showHints'], isFalse);
  expect(v1.containsKey('showHints'), isTrue);
});
```

A minimum migration suite includes:

1. A missing version is interpreted as V1.
2. Every Vn fixture reaches the current version and preserves the expected fields.
3. Running the current version again produces the same result.
4. A malformed payload enters the selected recovery policy.
5. A future version is rejected explicitly.
6. After V4 is added, the V1 fixture still travels through V2 → V3 → V4.
7. The current snapshot survives an encode/decode round trip without data loss.

Fixtures contain neutral data created for tests. I do not copy a real user's persisted payload into the repository.

### Common mistakes and trade-offs

#### Persisting the entire `AppState`

**Symptom:** the payload grows, stale caches return after restart, and a UI refactor breaks the old decoder.

**Fix:** create a whitelist or dedicated DTO. New fields do not persist until they have an explicit reason to do so.

#### Decoding the current model before migration

**Symptom:** a migration exists, but the app still fails in `fromJson` for an old payload.

**Fix:** decode raw JSON into a map, migrate it to the current schema, and only then perform typed decoding.

#### Increasing the version before migration completes

**Symptom:** a migration step fails after state already carries the new version, so the next run skips incomplete work.

**Fix:** migrate a copy and assign the target version only at the end of a successful transition.

#### Caching the payload before `await save`

**Symptom:** the first write fails, but the next save with identical bytes is skipped as though the data were already on disk.

**Fix:** update the cache after a successful write and clear it on failure.

#### Treating `null` as delete at every layer

**Symptom:** an encoder or transform failure removes the persisted key.

**Fix:** separate save and delete APIs, or reject nullable payloads before storage.

#### Using an excessively long throttle

**Symptom:** the app is killed before the timer runs and loses the latest change.

**Fix:** keep the window short, identify boundaries that require an immediate flush, and test the real lifecycle.

#### Making partial recovery too broad

**Symptom:** some fields recover but no longer match the defaults of other fields.

**Fix:** recover only a small, independent, validated data set with fixture coverage. Otherwise, fail in a controlled way.

A custom persistor gives me control over failure policy, but the team then owns locking, migrations, tests, and observability. `redux_persist` 0.9.0 provides middleware, serializers, transforms, and a storage abstraction. The protections in this article do not appear automatically after adding the dependency.

### Related articles

If your feature boundaries, reducers, and selectors are not clear yet, organize the Redux store first. This article focuses only on which state survives across app sessions.

{% content-ref url="/pages/flT55X8RgxKD0XpLXtUC" %}
[Redux/Flutter Redux at Scale](/flutter/my-flutter/architecture-state/redux-flutter-redux-large-app.md)
{% endcontent-ref %}

Redux Epics control concurrency for side effects in the action stream. Persistence middleware has its own storage concurrency policy, so I keep these responsibilities separate instead of putting save logic inside each feature Epic.

{% content-ref url="/pages/YyM552Bk6JgngO4Jb0D9" %}
[Redux Epics and RxDart](/flutter/my-flutter/architecture-state/redux-epics-rxdart.md)
{% endcontent-ref %}

### Verified versions

* Flutter: 3.41.2.
* Dart: 3.11 to before 4.0.
* `redux`: 5.0.0.
* `redux_persist`: 0.9.0.
* `redux_persist_flutter`: 0.9.0.
* `synchronized`: 3.4.0.
* Platform: Shared.
* Verified on: 2026-08-25.

`redux_persist` 0.9.0 was still the current version on pub.dev on the verification date; the package page states that this version was published five years earlier. Because my implementation extends the default behavior with a custom persistor, upgrading or replacing the package must run the full persistence test suite instead of checking only whether the API compiles.

### References

* [redux\_persist — package and usage guide](https://pub.dev/packages/redux_persist)
* [redux\_persist — source repository](https://github.com/Cretezy/redux_persist)
* [json\_serializable — JSON conversion code generation](https://pub.dev/packages/json_serializable)

## Conclusion

Persisted state becomes reliable by controlling what is written, not by calling `save` more often. I limit the stored schema, put a version in the payload, migrate one step at a time before hydration, and serialize storage operations with one lock.

When loading has not established the old data, persistence remains suspended instead of letting the initial state write over it. When a write fails, the payload is not marked as saved, so the next attempt can retry it.

This approach fits Redux state that must survive multiple app sessions while its schema continues to evolve. For a small preference that can be recreated or has little value, a simple storage adapter may be enough, and a custom persistor would add unnecessary cost.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
