In React Native, what changes for a persisted Zustand or redux-persist store when its storage adapter moves from AsyncStorage to MMKV?
answer
- AsyncStorage returns promises, MMKV returns values
- Zustand hydrates at creation with sync storage
- redux-persist stays promise-based anyway
- JSON.stringify still runs on every write
- old AsyncStorage data does not move itself
basics
~20 sMMKV reads and writes synchronously, so a Zustand persist store hydrates while it is created and needs no loading gate, while redux-persist wraps it in promises and stays asynchronous. Serialization still runs on the JavaScript thread, and existing AsyncStorage data must be migrated.
solid answer
~50 sAsyncStorage's API is promise-based; MMKV v4's `createMMKV()` instance returns values directly from `getString`, `set` and `remove`. Both plug into a persisted store through a small adapter with `getItem`, `setItem` and `removeItem`. With Zustand's `persist` and `createJSONStorage(() => mmkvAdapter)`, hydration happens synchronously at store creation, so the first render has the restored workout and the hydration gate disappears. redux-persist's `getStoredState` always goes through a promise, so its MMKV wrapper returns promises and `PersistGate` still does its job. Writes become synchronous calls on the JavaScript thread; the store still runs `JSON.stringify` over the persisted state on each change, so a huge log stays expensive. MMKV needs native code and its Nitro Modules dependency, so an Expo app needs a development build via prebuild. And switching does not carry data over: run a one-time copy from AsyncStorage before the store hydrates, or users lose their saved workout.
code
typescript · 25 linesimport { createMMKV } from 'react-native-mmkv';
import { create } from 'zustand';
import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware';
const mmkv = createMMKV();
const mmkvStorage: StateStorage = {
getItem: (name) => mmkv.getString(name) ?? null,
setItem: (name, value) => mmkv.set(name, value),
removeItem: (name) => {
mmkv.remove(name);
},
};
type WorkoutState = { log: string[]; addSet: (s: string) => void };
export const useWorkout = create<WorkoutState>()(
persist(
(set) => ({ log: [], addSet: (s) => set((st) => ({ log: [...st.log, s] })) }),
{ name: 'workout', storage: createJSONStorage(() => mmkvStorage) },
),
);
// Synchronous storage: already hydrated by the time any component renders.
console.log(useWorkout.persist.hasHydrated()); // truego deeper
Recall that AsyncStorage returns promises and MMKV returns values directly, and that both plug into a persisted store through an adapter.
Explain how synchronous storage changes Zustand's hydration timing, and why redux-persist stays asynchronous regardless.
Plan the switch for existing users: a one-time data copy ordered before hydration, the native build requirement, and the remaining serialization cost.
Weigh a new native dependency against startup gains, and decide whether persisted size, not storage speed, is the real bottleneck.
## The two storage models **AsyncStorage** (`@react-native-async-storage/async-storage`) is a key-value store of strings with a **promise-based** API: `getItem`, `setItem` and `removeItem` all return promises. In version 3 it became instance-based through `createAsyncStorage(name)`, while the default export still points at the previous storage so existing data remains readable. **MMKV** (`react-native-mmkv`, version 4) is a native key-value store you create with `createMMKV()`. Its calls are **synchronous**: `getString` returns the value, `set` stores it and `remove` deletes it. Version 4 is a Nitro Module, so it needs `react-native-nitro-modules` installed and native code compiled into the app. | | AsyncStorage | MMKV v4 | |---|---|---| | read | `await getItem(key)` | `getString(key)` returns now | | write | `await setItem(key, value)` | `set(key, value)` returns now | | setup | JavaScript install plus native module | plus `react-native-nitro-modules`; Expo needs prebuild | | value types | strings | strings, numbers, booleans, buffers | ## Plugging either into a persisted store Both persist layers accept any object with `getItem`, `setItem` and `removeItem`: - **Zustand**: pass `storage: createJSONStorage(() => adapter)`. The MMKV docs show an adapter whose methods call `getString`, `set` and `remove` and return their results directly. - **redux-persist**: pass `storage: adapter`. The MMKV docs show a wrapper that calls MMKV synchronously but returns `Promise.resolve(...)`, because redux-persist expects promises. ## What actually changes 1. **Hydration timing in Zustand.** With a synchronous storage, the persist middleware reads, migrates and merges the stored state during store creation. The first render already has the unfinished workout, `persist.hasHydrated()` is true from the start, and the root-level gate that AsyncStorage required becomes unnecessary. 2. **Hydration timing in redux-persist does not change much.** Its restore path reads through `storage.getItem(...).then(...)`, so it stays asynchronous by design and `PersistGate` is still required, only shorter. 3. **Writes move onto the call.** An MMKV `set` completes before the call returns, rather than being handed to a native module to finish later. For an app that is killed mid-workout, the last change is less likely to be caught in an unfinished asynchronous write. 4. **Serialization cost stays.** Both persist layers serialize with `JSON.stringify` by default. Zustand serializes the partialized state on each change; redux-persist serializes per top-level key, with an optional `throttle`. MMKV makes the storage step cheap, not the stringify, so a very large log still costs JavaScript-thread time per tap; `partialize` and smaller persisted shapes remain the fix. ## Moving existing users over Switching the adapter does **not** move data. On the first launch after the update, the store reads MMKV, finds nothing, and starts empty; the saved workout is still in AsyncStorage. The MMKV docs include a migration script that copies every AsyncStorage key into MMKV and deletes it from AsyncStorage, guarded by a flag stored in MMKV. For a persisted store: - run the copy **before** the store is created or hydrated, which is awkward because the copy is asynchronous and synchronous hydration happens at creation, so create the store after the copy or rehydrate it afterwards; - copy the persisted store's key as a string, unchanged, so the store's own `version` and `migrate` still apply; - keep the flag, so the copy runs once. ## When AsyncStorage is still the right call - a project that would rather not add MMKV's native build step and its Nitro Modules dependency; - small persisted state where hydration is fast and a gate is acceptable; - a team that prefers not to add another native dependency. MMKV's encryption option is a separate topic from choosing an adapter; secrets still belong in secure storage.
- Why does PersistGate still matter with MMKV under redux-persist?redux-persist's restore path is promise-based: it reads with `storage.getItem(...).then(...)` and dispatches `REHYDRATE` afterwards. Even when the MMKV call inside the adapter is synchronous, the result arrives in a later microtask, after React has had a chance to render. `PersistGate` still covers that gap; it is just shorter.
- After switching to MMKV, users report their unfinished workout vanished on update. What was missed?The data migration. The new adapter reads MMKV, which is empty on the first launch after the update, so the store hydrates with nothing, and its next write fills MMKV with the empty state. Copy the persisted key from AsyncStorage into MMKV once, before the store hydrates, and record that the copy ran.
saying these in an interview costs you the question
- MMKV makes the store's JSON serialization free, so persisted size no longer matters
- redux-persist hydrates synchronously once the storage is MMKV
- Switching the adapter to MMKV moves existing AsyncStorage data automatically
- MMKV v4 works in Expo Go with no native build
- AsyncStorage 3's default export reads from a new, empty storage area