What it actually is
MMKV is a key-value store written in C++ at Tencent, used in WeChat, and wrapped for React Native by Marc Rousavy. The wrapper installs through JSI rather than the old bridge, so JavaScript holds a direct reference to the native object. That is the entire technical story, and it explains every practical difference: no serialisation across a bridge, no promises, no queue.
It is still a key-value store. It is not a database, it does not query, and it is not the place for a thousand-row list — that is what expo-sqlite is for. Think of it as AsyncStorage with the await removed and a few extra types, and you will use it correctly.
Install, and the build you now need
npx expo install react-native-mmkv # then a native build — MMKV is not in Expo Go npx expo run:ios npx expo run:android # or a cloud build for teammates and devices you do not have eas build --profile development --platform all
If you install it and keep running in Expo Go, you get a runtime error along the lines of the MMKV native module not being available — not a build failure, so it is easy to misread as a configuration problem. It is not: the binary simply does not contain the library. A development build is a one-time cost and unlocks every other native module too, but it is a real decision, especially early in a project when Expo Go is doing a lot of work for you.
One instance, one typed module
// lib/storage.ts
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({ id: 'app' });
// A separate file per concern keeps a cache wipe from touching settings.
export const cache = new MMKV({ id: 'cache' });
export const KEYS = {
theme: 'settings.theme.v1',
onboarded: 'settings.onboarded.v1',
draft: 'compose.draft.v1',
} as const;
export function getJSON<T>(key: string, fallback: T): T {
const raw = storage.getString(key);
if (raw == null) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
storage.delete(key); // a corrupt value is a cache miss, not a crash
return fallback;
}
}
export function setJSON(key: string, value: unknown) {
storage.set(key, JSON.stringify(value));
}Primitives skip the JSON round-trip entirely, which is the small daily pleasure of using MMKV:
storage.set(KEYS.onboarded, true);
storage.set('launchCount', (storage.getNumber('launchCount') ?? 0) + 1);
const theme = storage.getString(KEYS.theme) ?? 'system'; // no await, usable in render
const seen = storage.getBoolean(KEYS.onboarded) ?? false;
storage.delete(KEYS.draft);
storage.getAllKeys(); // returns synchronously tooNamed instances are worth setting up on day one. A single namespace means your “clear cache” button either wipes user settings too or grows a hand-maintained list of keys to spare. Two instances make it one call.
The bug this actually fixes
Compare the two shapes side by side. With AsyncStorage, the value arrives after the first render, so you hold a flag and gate the tree:
// AsyncStorage: renders once with the wrong value, then corrects
const [theme, setTheme] = useState('system');
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
AsyncStorage.getItem('theme').then((t) => { setTheme(t ?? 'system'); setHydrated(true); });
}, []);
if (!hydrated) return null;
// MMKV: correct on the very first frame
const [theme, setTheme] = useState(() => storage.getString('theme') ?? 'system');The library also ships reactive hooks, which re-render on write from anywhere in the app — including another instance of the same key set in a completely different screen:
import { useMMKVString, useMMKVBoolean } from 'react-native-mmkv';
function ThemeToggle() {
const [theme, setTheme] = useMMKVString(KEYS.theme, storage);
return <Switch value={theme === 'dark'} onValueChange={(v) => setTheme(v ? 'dark' : 'light')} />;
}That is genuinely convenient for small global settings and genuinely a trap for anything bigger. Storage is not a state manager: putting a form draft or a selected filter behind these hooks means every keystroke hits disk and every consumer re-renders. Keep the hooks for values that change a few times a session.
Migrating off AsyncStorage
// Run once, before the first render that reads storage.
const MIGRATED = 'migrated.mmkv.v1';
export async function migrateFromAsyncStorage() {
if (storage.getBoolean(MIGRATED)) return;
const keys = await AsyncStorage.getAllKeys();
const pairs = await AsyncStorage.multiGet(keys);
for (const [key, value] of pairs) {
if (value != null) storage.set(key, value); // values are already strings
}
storage.set(MIGRATED, true);
// Leave the AsyncStorage copy in place for one release, in case you roll back.
}Two cautions. Do not delete the AsyncStorage data in the same release — if you ship a fix that reverts the change, or a user rolls back through an over-the-air update, the old code path needs something to read. And be aware that libraries you did not write may still hold their own AsyncStorage keys; migrating the whole namespace copies them, but those libraries keep reading from the original store regardless.
MMKV against AsyncStorage, honestly
| MMKV | AsyncStorage | |
|---|---|---|
| API | Synchronous, returns values | Promise-based, needs await |
| Expo Go | Not supported — needs a dev build | Works as-is |
| Read during render | Yes | No — causes the hydration flash |
| Types | string, number, boolean, Uint8Array | Strings only, so JSON everywhere |
| Encryption | Optional, with a key you must store safely | None |
| Size ceiling | No fixed cap, meant for small values | 6 MB default on Android |
| Multiple stores | Named instances with separate files | One shared namespace |
The recommendation that survives contact with real projects: start on AsyncStorage, because Expo Go is worth a lot in the first weeks and most apps read half a dozen keys at launch. Move to MMKV when you have already left Expo Go for another reason, or when you are actively fighting hydration ordering. Moving for benchmark numbers alone, in an app that stores a theme and an onboarding flag, is optimising something no user will ever perceive.
Skip the plumbing
Storage module, key registry, migration guard — the same afternoon in every project, before you write a feature. Describe your app at shipnative.dev and it generates a React Native app with persistence already wired correctly, running on your phone in minutes, with the full Expo project available to export.