A React Native release renames a field in the persisted workout store; how do version and migrate protect returning users' unfinished workouts, and what makes migrations fail silently?
answer
- users skip releases, stored shapes do not
- bump version with every shape change
- createMigrate runs each step in order
- a failed migration restores nothing
- the next write erases the old entry
basics
~20 sThe saved entry carries a version; when it differs from the code's, the store runs your migrate step to convert the old shape. Migrations fail silently on a missed bump, a throwing step or a timeout: the store starts empty and its next write erases the workout.
solid answer
~40 sMobile users update from any old release, so version 7 may read a workout saved by version 3. Zustand's `persist` stores `version` (default `0`) and calls `migrate(persistedState, version)`; redux-persist stores `version` (default `-1`) and usually uses `createMigrate(migrations)`, which runs every numbered step above the stored version up to the current one, in order. Renaming `sets` to `entries` means bumping the version and adding a step that copies the field. The silent failures: a forgotten bump, so the old shape merges into new code; a migration that throws, which redux-persist turns into rehydrating with nothing and Zustand reports only to `onRehydrateStorage`; and redux-persist's 5-second timeout. Each leaves initial state, and the next persisted write replaces the stored entry, destroying the workout. An OTA rollback adds a downgrade: `createMigrate` passes the newer state through unchanged.
code
typescript · 22 linesimport AsyncStorage from '@react-native-async-storage/async-storage';
import { createMigrate, persistReducer } from 'redux-persist';
import { workoutReducer } from './workoutSlice';
// Stored shapes: v3 { sets: Entry[] } -> v4 { entries: Entry[] } -> v5 adds { notes: string }
const migrations = {
4: (state: any) => {
const { sets, ...rest } = state;
return { ...rest, entries: sets ?? [] };
},
5: (state: any) => ({ ...state, notes: state.notes ?? '' }),
};
export const persistedWorkout = persistReducer(
{
key: 'workout',
storage: AsyncStorage,
version: 5,
migrate: createMigrate(migrations, { debug: __DEV__ }),
},
workoutReducer,
);go deeper
Recall that a persisted store carries a version, and that changing the saved shape means bumping it and writing a migration.
Explain how createMigrate chooses and orders steps, and how Zustand's single migrate function has to chain them itself.
Enumerate the silent failures, bumps forgotten, throws, timeouts and downgrades, and show how a backup and fixture tests stop data loss.
Treat persisted shapes as a versioned schema with review, fixtures and a rollback policy that keeps changes additive across releases.
## Why stored shapes outlive releases on mobile A persisted store writes a **snapshot of today's state shape** to the device. On the web a deploy reaches everyone at once; on a phone it does not: - users skip releases and update from whatever build they had, so version 7 can read state written by version 3; - store rollouts are staged, so two versions are live for days; - with over-the-air updates, a rollback can put **older** JavaScript back on devices after newer JavaScript has already written state. So every change to the persisted shape, such as renaming a workout's `sets` to `entries`, is a data migration, whether or not you write one. ## The version stamp Both common persist layers store a version number beside the state: | | Zustand `persist` | redux-persist | |---|---|---| | option | `version` | `version` | | default | `0` | `-1` | | where stored | beside the state in the entry | `_persist.version` in the stored state | | migration hook | `migrate(persistedState, version)` | `migrate(state, currentVersion)`, usually built with `createMigrate` | When the stored version differs from the code's version, the migration hook runs before the state is merged in. ## Writing migrations **redux-persist.** `createMigrate(migrations, { debug })` takes an object keyed by version number. On restore it selects every key greater than the stored version and not greater than the current one, sorts them, and runs them in sequence, each receiving the previous step's output. A user on version 3 updating to 7 runs steps 4, 5, 6 and 7. **Zustand.** `migrate` is one function that receives the stored state and its version. You write the chain yourself, for example a series of `if (version < 4)` blocks that each upgrade one step, so an old entry passes through every step it missed. For the rename: 1. bump `version` from 3 to 4; 2. add the step: copy `sets` into `entries` and drop `sets`; 3. default any field a very old entry might lack. ## How migrations fail silently | Failure | What the library does | What the user sees | |---|---|---| | shape changed, version not bumped | no migration; stored keys merge over the initial state | old field present, new field missing, possible crash | | Zustand version changed, no `migrate` provided | logs an error, does not apply the stored state | empty workout | | migration throws | redux-persist rehydrates with nothing; Zustand passes the error to the `onRehydrateStorage` callback | empty workout | | redux-persist read slower than its 5000 ms default `timeout` | rehydrates with nothing and ignores the late result | empty workout | | stored version higher than code (rollback) | `createMigrate` logs that downgrading is not supported and returns the state unchanged | newer shape read by older code | The empty-workout rows get worse after startup: the store continues from its **initial state**, and its next persisted write **replaces the stored entry**. The unfinished workout, which was still recoverable on disk, is overwritten a moment later. ## Making migrations safe - **Bump the version on every persisted-shape change.** Treat it like a database schema version and review it in code review. - **Test with fixtures.** Keep a stored blob from each released version and assert that migrating it produces the current shape. - **Write tolerant steps.** Use defaults for missing fields; never assume an intermediate step ran. - **Guard the failure path.** In `onRehydrateStorage` or before `PersistGate` lifts, detect a failed restore and copy the raw entry to a backup key before anything writes, so support can recover it. - **Plan for rollbacks.** If OTA rollbacks are possible, keep changes additive for one release, so older code can still read the newer shape. ## The shallow-merge trap Even with correct versions, both libraries merge restored state into initial state shallowly by default. A nested field added in a new release can therefore arrive as `undefined` for returning users; that is a merge problem rather than a migration one, and deserves its own fix.
- Why is a migration that throws worse than it first looks?The library treats the restore as failed and starts the store from its initial state; the saved entry is still on disk at that moment. But persistence keeps running, so the next state change writes the initial-based state over that entry. Detect the failure in `onRehydrateStorage` or before the gate lifts and back up the raw entry first.
- How can an OTA rollback break a persisted store, and how do you prepare for it?After a newer JavaScript bundle migrates the stored workout to version 5, a rollback runs older code with version 4. redux-persist's `createMigrate` logs that downgrades are unsupported and passes the version 5 state through, so old code reads a shape it does not know. Keep shape changes additive for a release so the previous code can still read them.
saying these in an interview costs you the question
- Users always update one release at a time, so each migration only needs the previous shape
- A migration that throws leaves the saved data safely on disk
- createMigrate runs only the migration keyed by the current version
- You only need to bump version when a field is removed
- redux-persist migrates a newer stored state down to an older version