Skip to content

React Native MMKV Not Persisting After Restart? It Was Nitro, Not MMKV (RN 0.87 Fix)

โฑ 11 min read
#React Native#MMKV#Nitro Modules#Zustand#Persistence#React Native 0.87#Debugging#Hermes

react-native-mmkv not persisting after a React Native 0.87 upgrade? Nitro failed to install and Zustand persist fell back to memory. Here is the fix.

We upgraded a React Native app from 0.86 to 0.87. Nothing about storage changed. Then a tester reported the classic symptom of react-native-mmkv not persisting data after an app restart: close the app, open it again, and it asks you to log in. Every time.

The persist config was correct. The server was healthy. MMKV's file was sitting right there on disk with a valid session inside it. And yet the app behaved as if it had never seen it.

This post is the whole investigation, in the order it actually happened, including the two dead ends. If you only want the answer: it was react-native-nitro-modules, not MMKV, and the fix is a version bump. But the why is worth knowing, because the same failure can hide behind any Nitro-based library after a React Native upgrade.


๐Ÿงฑ The Setup

The stack is a bare React Native app on the New Architecture with Hermes:

  • react-native-mmkv 4.x, which since v4 is a Nitro Module rather than a plain JSI module.
  • react-native-nitro-modules 0.35.10, the runtime MMKV v4 sits on.
  • Zustand with the persist middleware, using createJSONStorage over a tiny MMKV adapter.

If you have used Zustand with MMKV before, the adapter will look familiar. It is the same shape every tutorial on Zustand persist with MMKV recommends:

// src/lib/storage.ts
import { createMMKV } from 'react-native-mmkv';
import type { StateStorage } from 'zustand/middleware';

export const storage = createMMKV({ id: 'app' });

export const zustandMMKVStorage: StateStorage = {
  setItem: (name, value) => storage.set(name, value),
  getItem: name => storage.getString(name) ?? null,
  removeItem: name => storage.remove(name),
};
// src/store/useAppStore.ts
export const useAppStore = create<AppState>()(
  persist(set => ({ /* ... */ }), {
    name: 'app-store',
    storage: createJSONStorage(() => zustandMMKVStorage),
    partialize: state => ({
      hasOnboarded: state.hasOnboarded,
      isAuthenticated: state.isAuthenticated,
      session: state.session,
    }),
  }),
);

isAuthenticated and session are in partialize, so a signed-in user should stay signed in. For months, they did. For a deeper look at how this store fits into the app, see my guide to React Native state management with Zustand.


๐Ÿ› The Symptom

Sign in. Kill the app. Reopen it. Login screen.

Tested three or four times on the simulator, same result every time. Nothing in the JS code touching persistence had changed in the same window as the upgrade, which is exactly the kind of coincidence that sends you looking in the wrong places first.


๐Ÿ” Dead End 1 โ€” Is the Persist Config Wrong?

First stop was the obvious one. Is session actually persisted? Is the MMKV id stable? Is createJSONStorage wired to the right adapter? Is there a version mismatch that would trigger a migration and wipe state?

All fine. The config above is the config that had been working.


๐Ÿ” Dead End 2 โ€” Is the Server Signing Us Out?

Our API client has a rule: a 401 on an authenticated request calls signOut(), because it means the server-side session died. Our login returns no token; the server identifies the user by a session cookie plus a uuid header. Session cookies are discarded when an iOS app terminates. So the theory was tempting: relaunch, first request has no cookie, server returns 401, interceptor signs the user out, login screen.

It would have explained everything. It was wrong. Two curl calls settled it:

# uuid header only, no cookie โ†’ 200 with the bookings list
curl -X POST "$API" -H "Authorization: $APP_KEY" -H "uuid: $USER_UUID" \
  --data "action=bookings/list"

# bogus uuid โ†’ 403, not 401
curl -X POST "$API" -H "Authorization: $APP_KEY" -H "uuid: nope" \
  --data "action=bookings/list"

The uuid header alone authenticates. The 401 path never fires. Rule out the network from outside the app before you go digging inside it; it is the cheapest check you have.


๐Ÿ—‚๏ธ The First Real Clue โ€” The File on Disk

Instead of guessing, I went to look at what MMKV had actually written. On the iOS Simulator you can get the app's data container directly:

xcrun simctl get_app_container <UDID> com.example.myapp data
# โ†’ .../Containers/Data/Application/<id>

find <container> -path "*mmkv*" -type f
# โ†’ Documents/mmkv/app
# โ†’ Documents/mmkv/app.crc

strings <container>/Documents/mmkv/app | grep isAuthenticated

The file existed. It contained "hasOnboarded":true,"isAuthenticated":true and a full session with the user's uuid. So MMKV had persisted a session.

Then two details that did not fit:

  1. The file's modification time was two months old. Every sign-in since should have rewritten it.
  2. When I relaunched the app and took a screenshot, it landed on onboarding, not even the login screen. The file said hasOnboarded: true. The app was behaving as if the store had hydrated from nothing.

So the store was neither reading nor writing that file. Writes were going somewhere, and reads were coming back empty. That smells like a different storage object than the one on disk.

MMKV v4 has an in-memory mock it uses under Jest. Was the app somehow taking that path? Its isTest() check only looks at process.env.JEST_WORKER_ID and VITEST_WORKER_ID, which a device bundle should never have. Plausible, but unproven, and I did not want to ship a fix on a guess.


๐Ÿ”ฌ Asking the Running App โ€” Hermes Inspector Without DevTools

The decisive step was to interrogate the live app rather than reason about it. React Native's dev server exposes the Hermes debugger over the Chrome DevTools Protocol, so a few lines of Node can evaluate arbitrary expressions inside the running bundle:

curl -s http://localhost:8081/json
# โ†’ [{ "title": "com.example.myapp (iPhone 17 Pro)", "webSocketDebuggerUrl": "ws://localhost:8081/inspector/debug?device=...&page=1" }]
// probe.js โ€” minimal CDP client
const WebSocket = require('ws');
const ws = new WebSocket(process.argv[2], {
  // The inspector proxy rejects sockets without a matching Origin (401).
  headers: { Origin: 'http://localhost:8081' },
});
ws.on('open', () => {
  ws.send(JSON.stringify({
    id: 1,
    method: 'Runtime.evaluate',
    params: { expression: EXPR, returnByValue: true },
  }));
});
ws.on('message', d => { console.log(JSON.parse(d).result?.result?.value); ws.close(); });

Two expressions told the whole story. First, the test flag really was absent:

JSON.stringify({ jest: process.env.JEST_WORKER_ID, env: process.env.NODE_ENV })
// โ†’ {"env":"development"}   (no JEST_WORKER_ID โ†’ not the mock path)

Second, Metro's module registry in dev builds is reachable as __r.getModules() (a Map in current Metro). Each record carries verboseName, isInitialized, hasError and the error itself. Filtering for the storage, store and Nitro modules:

[
  { "name": "src/store/useAppStore.ts", "isInitialized": true, "hasError": false },
  { "name": "src/lib/storage.ts", "isInitialized": false, "hasError": true,
    "error": "Error: Failed to install Nitro: installJSIBindingsWithRuntime: was not called - JSI Bindings could not be installed!" },
  { "name": "node_modules/react-native-nitro-modules/src/NitroModules.ts", "isInitialized": false, "hasError": true,
    "error": "Error: Failed to install Nitro: installJSIBindingsWithRuntime: was not called - JSI Bindings could not be installed!" }
]

There it was. createMMKV was throwing at module load, because Nitro had never installed its JSI bindings. The error message is the one you will find if you search for "Failed to install Nitro: installJSIBindingsWithRuntime: was not called".


๐Ÿคซ Why Nothing Crashed โ€” Zustand Swallowed It

If createMMKV throws, why did the app run at all? Because of two small, reasonable behaviours that combine badly:

  1. React Native's Babel preset uses inline requires, so storage.ts is not evaluated when the store file loads. It is evaluated lazily, inside the () => zustandMMKVStorage getter passed to createJSONStorage.
  2. createJSONStorage wraps that getter in a try/catch and returns undefined when it throws.

And when the persist middleware receives no storage, it does this:

// zustand/middleware (persist)
if (!storage) {
  return config((...args) => {
    console.warn(
      `[zustand persist middleware] Unable to update item '${options.name}', the given storage is currently unavailable.`,
    );
    set(...args);
  }, get, api);
}

A plain in-memory store, a console.warn on every write, and no persist API on the hook. That matched the probe exactly: useAppStore.persist was undefined, and the yellow "Open debugger to view warnings" toast on launch was this warning.

So if you are seeing "the given storage is currently unavailable" with MMKV, do not start by tuning your adapter. Ask why constructing the MMKV instance threw.


๐Ÿงจ Root Cause โ€” React Native 0.87 Changed a TurboModule Hook

React Native 0.87 changed how it asks a TurboModule to install JSI bindings. It now calls a two-argument installJSIBindingsWithRuntime:callInvoker:. react-native-nitro-modules up to 0.36.1 only implements the one-argument form, so on 0.87 React Native never calls it, Nitro's runtime never installs, and every Nitro-based library (MMKV v4 included) fails at construction with exactly the message above.

Nitro 0.36.2 added the overload ("Add support for RN 0.87+ by overloading new installJSIBindingsWithRuntime"). Our lockfile had 0.35.10.

The timeline explained the stale file too: persistence had been dead since the morning of the upgrade. Every sign-in since had gone into memory and evaporated on the next launch.


๐Ÿ› ๏ธ The Fix

One dependency bump, then a native rebuild, because this is native code:

npm install react-native-nitro-modules@^0.36.5
cd ios && pod install && cd ..
npm run ios      # and a fresh Android build too

Any Nitro release from 0.36.2 upward works on React Native 0.87. We went to 0.36.5, the last release in that line, rather than straight to 0.37, because MMKV 4.3.2 was built against the 0.35/0.36 generation and I wanted the smallest possible jump. If you are on React Native 0.88, go to 0.37.1 or later.

Verification was the mirror of the diagnosis:

  • The MMKV file's modification time updated the moment I signed in.
  • The probe showed src/lib/storage.ts with isInitialized: true and useAppStore.persist present.
  • Kill, relaunch: straight to Home, signed in.

๐Ÿ›ก๏ธ Don't Let It Be Silent Next Time

The bug was annoying. The silence was the real problem: a hard native failure degraded into a yellow toast most people dismiss. Two cheap guards would have surfaced it on day one.

Fail loudly in the adapter. Construct MMKV eagerly and let a failure be visible in development:

import { createMMKV, type MMKV } from 'react-native-mmkv';

function openStorage(): MMKV {
  try {
    return createMMKV({ id: 'app' });
  } catch (error) {
    if (__DEV__) {
      // Make the native failure impossible to miss while developing.
      console.error('[storage] MMKV failed to initialise; persistence is OFF', error);
    }
    throw error;
  }
}

export const storage = openStorage();

Assert hydration in a smoke test. A single test that signs in, re-creates the store from the same storage and expects isAuthenticated to be true would have failed in CI the day Nitro broke. We already had a store test suite, so this was a ten-line addition. If you need a starting point for that kind of test, my post on testing React Native apps with Jest covers the store and hook layer.

And a process note: after a React Native upgrade, re-verify every native dependency at runtime, not just at build time. This one compiled and linked perfectly. Nothing in Xcode or Gradle said a word. More of that mindset is in Debugging React Native Apps.


โ“ FAQ

Why is react-native-mmkv not persisting data after an app restart? Most often MMKV itself is fine and the instance never got created. With MMKV v4 that usually means react-native-nitro-modules failed to install, typically after a React Native upgrade. Check your Metro console for "Failed to install Nitro" and whether Zustand is warning that storage is "currently unavailable".

What does "Unable to update item โ€ฆ the given storage is currently unavailable" mean in Zustand? createJSONStorage caught an exception while obtaining your storage and handed the persist middleware nothing, so it is running in memory. The exception is the thing to find; the message is only the symptom.

Which react-native-nitro-modules version works with React Native 0.87? 0.36.2 or newer. 0.36.5 is the last of the 0.36 line; 0.37.1 is current and also covers React Native 0.88.

Does this affect Android too? The two-argument hook is on the iOS TurboModule path, which is where we reproduced it. Nitro 0.36.2+ is the right version on both platforms regardless, so upgrade once and rebuild both.


๐Ÿง  Key Lessons

  1. A valid file on disk plus a default-state app means the app is not reading that file. Check timestamps before you check logic.
  2. Rule out the server from outside the app. Two curls killed the most seductive theory in a minute.
  3. The Hermes inspector is a general-purpose debugger, not just DevTools' backend. Fifteen lines of Node let you read Metro's module registry, including module errors the UI never showed you.
  4. createJSONStorage swallows storage constructor errors. That is fine for optional storage; for a session store it hides outages. Guard the construction yourself.
  5. Nitro libraries fail at construction, not at build. MMKV, and anything else on Nitro, will compile happily against a React Native version it cannot run on.

๐Ÿ Closing Thoughts

The whole chain was: React Native 0.87 changed a hook, Nitro 0.35 did not know about it, createMMKV threw, createJSONStorage caught it, Zustand kept going in memory, and the user saw a login screen. Five reasonable behaviours, one bad outcome, and not a single stack trace in sight.

The fix was one line in package.json. The lesson is to make sure the next failure like it is loud.


Written by @iamhusnain

โ€” if you found this helpful, here is another debugging trail from the same app:

Sharing a React Native iOS Simulator Build โ€” and the Firebase Keychain (-34018) Bug .

Storage or upgrade trouble in your React Native app? Hire a React Native developer who has already walked this trail.

Let's bring your app idea to life

React Native apps for iOS & Android, from first commit to the store.

Share this article

Related Articles