react-native-mmkv — це синхронне ключ-значення сховище для React Native на базі бібліотеки MMKV від Tencent, яке у моїх бенчмарках працює у 25–30 разів швидше за AsyncStorage (0.52 мс на читання проти 2.55 мс, і 0.57 мс на запис проти 2.87 мс). У версії 4, переписаній на Nitro Modules, воно повернуло сумісність зі Старою Архітектурою, отримало пряме AES-256 шифрування на нативному рівні й потребує React Native 0.76+. Далі я покажу трейси, установку в Expo, шифрування, персистенцію Zustand і міграцію з AsyncStorage.
MMKV v4, це Nitro Module з JSI-байндингами; читання у ~4.9× швидше за AsyncStorage, запис у ~5.0× швидше в моїх вимірах на Pixel 7.
Синхронний API усуває мікропаузи гідратації Zustand persist на холодному старті (100–500 мс на AsyncStorage → <5 мс на MMKV).
Вбудоване AES-128/256 шифрування виконується у C++ шарі MMKVCore з апаратним прискоренням; ключ обмежений 16 байтами.
Не працює в Expo Go: потрібен expo prebuild або dev client, оскільки MMKV вимагає нативних модулів.
Для секретів (JWT, refresh-токени) використовуйте expo-secure-store, а MMKV-ключ шифрування теж зберігайте там.
Міграція з AsyncStorage робиться за один прохід через ітерацію ключів і перенесення в MMKV.set() без даунтайму користувача.
Що таке MMKV і чому він швидший за AsyncStorage?
MMKV, це memory-mapped key-value сховище, спочатку розроблене Tencent для WeChat (1+ мільярд користувачів) і портоване у React Native через обгортку react-native-mmkv від Marc Rousavy. На відміну від AsyncStorage, який під капотом на iOS використовує серіалізацію в RocksDB/SQLite і мостовий JSON-парсинг, MMKV мапить файл сховища напряму в пам'ять процесу через mmap(). Читання перетворюється на розіменування вказівника, а запис на пряме оновлення сторінок пам'яті, які ОС потім лінива синхронізує на диск.
Ось трейс з Hermes Sampling Profiler, який я зняв на Pixel 7 (Android 14, RN 0.79) для 1000 послідовних викликів getString:
AsyncStorage.getItem × 1000
├─ JS thread blocked: 2,548 ms total
├─ Bridge serialization: 1,142 ms (44.8%)
├─ Native SQLite read: 986 ms (38.7%)
└─ JSON parse на JS: 420 ms (16.5%)
MMKV.getString × 1000
├─ JS thread blocked: 520 ms total
├─ JSI call: 87 ms (16.7%)
├─ mmap read: 401 ms (77.1%)
└─ String copy на JS: 32 ms (6.2%)
Різниця у 4.9× йде саме зі знищення двох найповільніших ланок: моста та JSON-парсингу. У v4 бібліотека переписана на Nitro Modules, що замінює ручні JSI-байндинги генерованим C++-кодом. Це прибирає ~15% оверхеду на кожному виклику й, що важливіше, повертає підтримку Старої Архітектури (v3 працювала лише на Fabric/TurboModules).
MMKV зберігає значення у власному бінарному форматі, Protobuf-подібному. Числа лишаються числами, булеві займають один байт. Це означає, що storage.getNumber('count') не робить parseFloat: просто читає 8 байт і повертає їх у JS. Для порівняння: AsyncStorage все зберігає як рядок і кожне число проганяє через JSON.parse.
Як встановити react-native-mmkv v4 в Expo
MMKV не працює в Expo Go, тож вам потрібен development build. Процес однаковий для managed і bare workflow, якщо ви на SDK 52+ (React Native 0.76+).
Прапорець --clean потрібен, щоб очистити старий ios/ і android/, інакше Nitro codegen може не підхопитися. Далі створіть окремий модуль сховища. Я тримаю його в src/storage/mmkv.ts:
// src/storage/mmkv.ts
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({
id: 'app-default',
});
export const cache = new MMKV({
id: 'network-cache',
});
Розділяти сховища за id, до речі, це не косметика. Кожен інстанс, це окремий файл на диску, отже storage.clearAll() у кеші не зачепить основні дані користувача. У продакшн-додатку я тримаю щонайменше три інстанси: app-default для налаштувань, network-cache для TanStack Query persist і secure-vault для зашифрованих даних.
Як зашифрувати дані в MMKV
За замовчуванням MMKV пише файл у plain text, покладаючись на пісочницю iOS/Android. Для корпоративних додатків, банківських або медичних цього недостатньо, бо jailbroken/rooted пристрої дозволяють читати файли з /data/data напряму. Шифрування вмикається одним параметром:
import { MMKV } from 'react-native-mmkv';
import * as SecureStore from 'expo-secure-store';
async function createSecureStorage() {
let key = await SecureStore.getItemAsync('mmkv-encryption-key');
if (!key) {
// 16 байт випадкового ключа, потім base64 → 24 символи
const bytes = await Crypto.getRandomBytesAsync(16);
key = btoa(String.fromCharCode(...bytes)).slice(0, 16);
await SecureStore.setItemAsync('mmkv-encryption-key', key);
}
return new MMKV({
id: 'secure-vault',
encryptionKey: key,
});
}
Ключ обмежений 16 байтами. Це жорсткий ліміт нативного C++ шару MMKVCore, і бібліотека кине помилку на 17-му байті. Я на цю штуку натрапив в останньому проєкті, коли перекладав UUID як ключ (32 hex-символи), і воно тихо ламало запис. Шифрування виконується апаратно на iOS через CommonCrypto й на Android через javax.crypto.Cipher, які використовують AES-NI інструкції процесора. У моїх вимірах шифрований запис коштує додаткові ~0.3 мс на операцію, що незначущо порівняно з мостовим оверхедом AsyncStorage.
Ключ шифрування повинен жити в Keychain/Keystore, а не хардкодитися. Для цього ідеально підходить expo-secure-store, який використовує захищений апаратний enclave. Метод storage.recrypt(newKey) дозволяє змінити ключ атомарно: усі значення перешифровуються за один прохід через reKey() у MMKVCore.
Інтеграція MMKV з Zustand persist
Zustand persist middleware очікує StateStorage адаптер із трьома методами. Оскільки MMKV синхронний, адаптер тривіальний і не потребує обгортання у промі:
Різниця, яку я міряв на реальному кейсі (застосунок з 47 KB persist-стану): гідратація Zustand на старті займала 187 мс на AsyncStorage проти 4 мс на MMKV. Це усуває "flash of unstyled state" на splash screen, коли тема ще light, а користувач у налаштуваннях вибрав dark. У Reactotron трейсі це видно як зникнення першого кадру з неправильною темою.
Коли використовувати MMKV, а коли SecureStore чи SQLite
Критерій
MMKV
expo-secure-store
expo-sqlite
AsyncStorage
Швидкість читання
0.52 мс
~5 мс
~2 мс (індексоване)
2.55 мс
API
Синхронний
Асинхронний
Асинхронний
Асинхронний
Шифрування
AES-128/256
Keychain/Keystore
Опційне (SQLCipher)
Немає
Максимальний розмір
~1 МБ на ключ
~2 КБ на iOS
Гігабайти
6 МБ загалом на Android
Запити/індекси
Ні
Ні
SQL
Ні
Expo Go
Ні
Так
Так
Так
Кращий випадок
Стан, кеш, налаштування
JWT, refresh-токени
Реляційні дані
Легасі, сумісність
Правило, за яким я розкладаю сховище в новому проєкті: MMKV забирає все, що читається на кожному старті (тема, локаль, feature-flags, persist-стан). SecureStore відповідає за все, що коштує грошей у разі витоку (токени, PIN, платіжні реквізити). SQLite бере на себе все, де є зв'язки й запити (список замовлень, чати, синхронізована офлайн-БД). AsyncStorage лишається лише в застосунках, де ще підтримується Expo Go або йде поетапна міграція.
Один нюанс: expo-secure-store на iOS має ліміт ~2 КБ на одне значення. Це означає, що великі access-токени JWT з великою кількістю claims туди не влізуть. Обхід простий: тримайте ключ шифрування в SecureStore, а сам зашифрований токен у MMKV.
Як мігрувати з AsyncStorage на MMKV
Міграція робиться один раз при першому запуску нової версії. Я тримаю прапорець в MMKV, який позначає, що міграція вже завершилася:
import AsyncStorage from '@react-native-async-storage/async-storage';
import { storage } from './mmkv';
const MIGRATION_FLAG = 'migrated-from-async-storage-v1';
export async function migrateFromAsyncStorage() {
if (storage.getBoolean(MIGRATION_FLAG)) return;
const start = performance.now();
const keys = await AsyncStorage.getAllKeys();
const pairs = await AsyncStorage.multiGet(keys);
for (const [key, value] of pairs) {
if (value === null) continue;
storage.set(key, value);
}
storage.set(MIGRATION_FLAG, true);
// Не видаляйте AsyncStorage одразу, лишіть на 1-2 релізи як safety net
console.log(
`[MMKV] Migrated ${pairs.length} keys in ${(performance.now() - start).toFixed(1)}ms`
);
}
Викликайте migrateFromAsyncStorage() у useEffect кореневого компонента до маунта решти дерева, або ще раніше, в index.js перед registerRootComponent. Тільки не паралельте це з іншими операціями сховища: під час міграції ви пишете в MMKV, і паралельне читання Zustand persist поверне ще null.
Я лишаю AsyncStorage у застосунку ще на два релізи як fallback. Якщо якийсь модуль забув оновити на MMKV, дані там принаймні лишаться. У третьому релізі викидаю пакет із package.json і чищу файли AsyncStorage через AsyncStorage.clear() перед видаленням. Це також економить ~200 КБ у бандлі.
Профілювання MMKV: реальні числа з Hermes Sampling Profiler
Чесно, я не довіряю бенчмаркам зі README без власного трейсу. Ось методика, якою я міряю сховище на кожному новому пристрої (вона підходить і для порівняння MMKV з альтернативами). Про повний набір інструментів я писав у гайді з зневадження React Native додатків з Hermes Profiler і Reactotron.
Для порівняння, AsyncStorage на тому ж Samsung A24 давав 6.8 мс/операція середнє й 22 мс на p99. Ця різниця критична для сценарію "гідратація перед першим рендером": на mid-range Android ви або встигаєте до 60 fps дедлайну (16.6 мс), або пропускаєте перші два кадри. MMKV поміщається, AsyncStorage ні.
Для реальної продакшн-телеметрії я обгортаю виклики MMKV у Performance.mark() і відсилаю у Sentry. Якщо p99 виростає >5 мс, це сигнал, що файл сховища роздувся до сотень мегабайт: час викликати storage.trim() або перевіряти витоки, коли якийсь код пише у тіснішому циклі.
Кілька інстансів і мультипроцесна ізоляція
MMKV підтримує кілька незалежних інстансів через id і path. Це критично для трьох сценаріїв: багатокористувацькі додатки (сховище на юзера), extension targets на iOS (share extension, widgets) і фонові задачі, які пишуть у той самий файл, що й основний процес.
import { MMKV } from 'react-native-mmkv';
// Ізольоване сховище на кожного користувача
export function getUserStorage(userId: string) {
return new MMKV({
id: `user-${userId}`,
});
}
// Shared між app і widget target на iOS
export const sharedStorage = new MMKV({
id: 'shared-with-widget',
path: `${FileSystem.documentDirectory}Group/`, // App Group
mode: 'multi-process',
});
Прапорець mode: 'multi-process' вмикає flock() синхронізацію між процесами. Це трохи повільніше (~0.15 мс оверхеду на запис), але без нього два процеси, що пишуть одночасно, можуть корумпувати файл. Для App Groups на iOS обов'язково додайте entitlement com.apple.security.application-groups і використайте той самий group identifier у widget extension.
Одна корисна річ, яку я випадково знайшов у трейсі: new MMKV({...}) з тим самим id повертає той самий нативний інстанс. Тобто кешувати конструктор у React Context не обов'язково, MMKV робить це сам. Це прибирає багато шаблонного коду.
Часті питання
Часті питання
Чи можна використовувати MMKV в Expo Go?
Ні. MMKV, це нативний модуль з C++ кодом, і йому потрібна компіляція в бінарнику застосунку. Використовуйте npx expo prebuild і development build через EAS або локально. Для швидкого прототипування без нативної збірки лишайтеся на AsyncStorage і мігруйте пізніше.
Наскільки безпечне шифрування MMKV?
MMKV використовує AES-128 або AES-256 CFB з апаратним прискоренням через AES-NI. Це криптографічно безпечно для даних у стані спокою на пристрої. Слабка ланка тут, це зберігання самого ключа: тримайте його в Keychain/Keystore через expo-secure-store, а не в коді чи AsyncStorage.
Чи потрібно чистити MMKV перед розмонтуванням застосунку?
Ні. MMKV мапить файл через mmap(), і ОС автоматично закриває мапінг при завершенні процесу. Якщо ви хочете форсовано скинути буфер на диск (наприклад, перед фоновою задачею), викличте storage.trim(). Ця операція заодно дефрагментує файл сховища.
Чи можна зберігати в MMKV об'єкти, а не тільки примітиви?
API приймає лише string | number | boolean | ArrayBuffer. Для об'єктів робіть storage.set('key', JSON.stringify(obj)) і JSON.parse(storage.getString('key')). Для великих об'єктів (>100 КБ) серіалізація JSON стає боттлнеком, тож розгляньте msgpack або зберігання як ArrayBuffer.
Що робити, якщо MMKV.set() падає з "Failed to write"?
Це означає, що файл сховища недоступний для запису або диск заповнений. Найчастіші причини: некоректний шлях у параметрі path, дозволи на директорію (перевірте на Android 13+ scoped storage) або переповнений диск. Для великих payload'ів (>1 МБ на ключ) MMKV не оптимізований, тож переходьте на файлову систему або SQLite.