DEV Community

Mohammad Rehan
Mohammad Rehan

Posted on

Supercharging MMKV Debugging in React Native: Bringing Full State Inspection and Diffs to Reactotron

How we connected MMKV to Reactotron using JavaScript Proxies, operation interception, and state-merging pipelines—without giving up MMKV’s performance.



The Problem: MMKV Made Our App Faster, but Debugging Harder

At some point, many React Native applications outgrow the convenience of AsyncStorage.

As the application grows, persistent storage becomes part of almost every important flow:

  • Authentication tokens
  • User preferences
  • Cached API responses
  • Feature flags
  • Session information
  • Offline data
  • Application configuration

We were using AsyncStorage, but as our application became more performance-sensitive, especially on low-end Android TV devices, we wanted a faster storage solution.

That led us to MMKV.

MMKV is a high-performance key-value storage library backed by memory-mapped files. The React Native implementation uses JSI, which allows JavaScript to communicate with native code without relying on the traditional asynchronous bridge for every operation.

The performance improvement was significant.

Reads and writes became synchronous and extremely fast.

But there was a catch.

We lost our debugging superpowers.

Our team relied heavily on Reactotron during development. With Redux and other state-management tools, Reactotron gave us a convenient place to:

  • Inspect application state
  • Track state changes
  • View actions
  • Inspect nested objects
  • See before/after values
  • Debug state transitions

After moving persistent state to MMKV, a large portion of that visibility disappeared.

The data was there.

The application was faster.

But Reactotron couldn't see what was happening inside MMKV.

We had effectively traded developer visibility for runtime performance.

We didn't want to make that trade-off.

So we built reactotron-plugin-mmkv.


1. Why a Simple MMKV Listener Wasn't Enough

Our first instinct was to use MMKV's native value-change listener.

MMKV provides a mechanism that tells you when a stored value changes.

That sounds like exactly what we need.

But there is an important distinction:

Knowing that a value changed isn't the same as knowing what operation caused the change.

For example, imagine these operations:

storage.set("theme", "dark")
storage.set("theme", "light")
storage.delete("theme")
storage.clearAll()
Enter fullscreen mode Exit fullscreen mode

A post-write listener can tell us that something changed.

But Reactotron needs much more useful information:

SET
UPDATE
DELETE
CLEAR_ALL
Enter fullscreen mode Exit fullscreen mode

And ideally:

theme

before: "light"
after:  "dark"
Enter fullscreen mode Exit fullscreen mode

The native listener fires after the operation has already happened.

At that point, we've lost important context.

The limitations became clear

A native change listener alone couldn't reliably provide:

  • The exact operation type
  • The previous value
  • A before/after diff
  • Delete information
  • Read operations
  • clearAll() context
  • Operation-specific metadata
  • A complete state representation for Reactotron's State tab

So we needed to intercept operations before they reached the native layer.

That led us to the core idea behind the plugin.


2. Intercept MMKV at the JavaScript Boundary

Instead of replacing MMKV, we wrapped it.

JavaScript's Proxy API gives us exactly the interception layer we needed.

Conceptually:

Application
     │
     ▼
┌──────────────────┐
│ MMKV Proxy       │
│                  │
│ set()            │
│ delete()         │
│ clearAll()       │
│ getString()      │
│ getNumber()      │
└────────┬─────────┘
         │
         ▼
   Native MMKV
Enter fullscreen mode Exit fullscreen mode

The application continues using the familiar MMKV API.

For example:

storage.set("theme", "dark");
Enter fullscreen mode Exit fullscreen mode

But instead of calling the native method directly, the Proxy gets the first opportunity to inspect the operation.

That gives us enough information to reconstruct the complete state transition.


3. How Proxy Mode Works

For a write operation such as:

storage.set("theme", "dark");
Enter fullscreen mode Exit fullscreen mode

the plugin can perform the following sequence:

1. Intercept set()
2. Check whether "theme" already exists
3. Read the previous value
4. Execute the original MMKV operation
5. Determine SET vs UPDATE
6. Build the operation payload
7. Send the event to Reactotron
Enter fullscreen mode Exit fullscreen mode

So if the key didn't exist:

🟢 MMKV SET

theme → "dark"
Enter fullscreen mode Exit fullscreen mode

If the key already contained "light":

🟡 MMKV UPDATE

theme
before: "light"
after:  "dark"
Enter fullscreen mode Exit fullscreen mode

And for deletion:

🔴 MMKV DELETE

theme
removed: "dark"
Enter fullscreen mode Exit fullscreen mode

This is significantly more useful than simply receiving:

theme changed
Enter fullscreen mode Exit fullscreen mode

4. The Operation Pipeline

At a high level, the Proxy follows this decision tree:

                    Application calls MMKV
                             │
                             ▼
                       Proxy intercepts
                             │
                    ┌────────┴────────┐
                    │                 │
                Ignored key       Supported method
                    │                 │
                    ▼                 ▼
              Native MMKV       Identify operation
                                      │
              ┌───────────────────────┼──────────────────────┐
              │                       │                      │
             set()                 delete()              clearAll()
              │                       │                      │
              ▼                       ▼                      ▼
        Read old value          Read old value         Count keys
              │                       │                      │
              ▼                       ▼                      ▼
        Native operation        Native operation      Native operation
              │                       │                      │
              ▼                       ▼                      ▼
        SET / UPDATE              DELETE                CLEAR_ALL
              │                       │                      │
              └───────────────────────┼──────────────────────┘
                                      ▼
                              Build Reactotron payload
                                      │
                                      ▼
                              Send operation to UI
Enter fullscreen mode Exit fullscreen mode

This approach also lets the plugin optionally observe reads.

For example:

storage.getString("theme");
Enter fullscreen mode Exit fullscreen mode

can be logged when read logging is enabled.

That can be useful when debugging unexpected storage access, although logging every read isn't something we'd recommend enabling by default in performance-sensitive applications.


5. Why We Added an Ignore List

Instrumentation itself has a cost.

If an application performs thousands of storage operations, logging every single one can become noisy and unnecessarily expensive.

So the plugin supports ignoring specific keys.

For example:

mmkvPlugin({
  storage,
  mode: "proxy",
  ignoredKeys: [
    "some_high_frequency_key",
    "temporary_cache"
  ]
});
Enter fullscreen mode Exit fullscreen mode

Ignored operations bypass the additional instrumentation and execute directly against the underlying MMKV instance.

This gives developers control over the observability/performance trade-off.


6. The Bigger Challenge: Reactotron's State Tab

Operation logging solved only half the problem.

We also wanted MMKV to appear inside Reactotron's State tab.

This is where things became more interesting.

MMKV itself is fundamentally a flat key-value store:

user_token
theme
language
user_settings
Enter fullscreen mode Exit fullscreen mode

But applications often store serialized JSON:

storage.set(
  "user_settings",
  JSON.stringify({
    volume: 80,
    notifications: true,
    profile: {
      name: "Alex"
    }
  })
);
Enter fullscreen mode Exit fullscreen mode

If Reactotron simply displayed the raw value, developers would see:

user_settings:
"{\"volume\":80,\"notifications\":true,\"profile\":{\"name\":\"Alex\"}}"
Enter fullscreen mode Exit fullscreen mode

That's technically correct.

But it's not a great debugging experience.

We wanted:

user_settings
├── volume: 80
├── notifications: true
└── profile
    └── name: "Alex"
Enter fullscreen mode Exit fullscreen mode

So the plugin needed its own state engine.


7. Building an MMKV State Engine

The state engine performs several jobs.

1. Read MMKV keys

It retrieves the keys stored in the MMKV instance.

2. Resolve nested paths

Reactotron can request specific paths rather than the entire state tree.

For example:

mmkv
mmkv.user_settings
mmkv.user_settings.profile
Enter fullscreen mode Exit fullscreen mode

The plugin resolves those paths dynamically.

3. Parse JSON values

When a stored string contains valid JSON, the plugin can expose it as a structured object.

Instead of:

"user_settings": "{\"volume\":80}"
Enter fullscreen mode Exit fullscreen mode

Reactotron can display:

"user_settings": {
  "volume": 80
}
Enter fullscreen mode Exit fullscreen mode

4. Handle problematic values

Debugging tools need to be defensive.

Large strings, deeply nested structures, and circular references can become surprisingly expensive to render.

The state engine therefore needs to protect Reactotron from excessively large or problematic payloads.

The goal isn't simply:

"Send everything to Reactotron."

The goal is:

"Send enough information to make debugging useful without turning the debugging tool into the performance problem."


8. The Redux Problem

There was another issue we didn't anticipate initially.

Our application wasn't using MMKV alone.

We also had Redux.

That meant Reactotron could have two different state providers:

Redux Plugin
     │
     └── Reactotron State

MMKV Plugin
     │
     └── Reactotron State
Enter fullscreen mode Exit fullscreen mode

Both plugins can potentially respond to Reactotron's state requests.

And that creates a collision.

Imagine Reactotron asks:

state.values.request
path: null
Enter fullscreen mode Exit fullscreen mode

The Redux plugin responds with:

{
  auth: {...},
  user: {...},
  cart: {...}
}
Enter fullscreen mode Exit fullscreen mode

The MMKV plugin responds with:

{
  mmkv: {
    theme: "dark",
    token: "...",
  }
}
Enter fullscreen mode Exit fullscreen mode

If those responses aren't coordinated, one can effectively overwrite the other.

Instead of seeing:

State
├── auth
├── user
├── cart
└── mmkv
Enter fullscreen mode Exit fullscreen mode

you may end up seeing only one side.

That's not what we wanted.


9. Merging Peer Plugin State

The solution was to intercept the Reactotron communication layer and merge state responses.

Conceptually:

Reactotron requests root state
              │
              ▼
       Redux responds
              │
              ▼
     MMKV interceptor
              │
        ┌─────┴─────┐
        │           │
   Cache Redux   Read MMKV
      state        state
        │           │
        └─────┬─────┘
              ▼
        Merge both trees
              │
              ▼
      Send merged state
              │
              ▼
        Reactotron UI
Enter fullscreen mode Exit fullscreen mode

The resulting tree becomes:

State
├── auth
├── user
├── cart
└── mmkv
    ├── user_token
    ├── theme
    └── user_settings
Enter fullscreen mode Exit fullscreen mode

This was one of the most important parts of the implementation.

The plugin wasn't just adding another state provider.

It was making MMKV behave like a cooperative peer alongside existing Reactotron state plugins.


10. Handling Standalone MMKV Usage

We also wanted the plugin to work when Redux wasn't installed.

That creates another edge case.

If the MMKV plugin waits for another state provider to respond, it could end up waiting forever in a standalone application.

The solution is a small fallback mechanism.

Conceptually:

Root state request
       │
       ▼
Wait briefly for peer response
       │
   ┌───┴────┐
   │        │
Response   No response
   │        │
   ▼        ▼
Merge     Return MMKV
state      state
Enter fullscreen mode Exit fullscreen mode

This allows the plugin to work in both environments:

Redux + MMKV
Enter fullscreen mode Exit fullscreen mode

and:

MMKV only
Enter fullscreen mode Exit fullscreen mode

without requiring developers to configure the application differently.


11. The Final Architecture

Putting everything together gives us four major layers:

┌─────────────────────────────────────────────┐
│              React Native App               │
│                                             │
│  Application Code                           │
│       │                                     │
│       ▼                                     │
│  MMKV Proxy                                 │
│       │                                     │
│       ▼                                     │
│  Native MMKV                                │
│                                             │
│  ┌───────────────────────────────────────┐  │
│  │ reactotron-plugin-mmkv                │  │
│  │                                       │  │
│  │ • Operation interception              │  │
│  │ • Diff generation                     │  │
│  │ • JSON parsing                        │  │
│  │ • State tree generation               │  │
│  │ • Path resolution                     │  │
│  │ • State merging                       │  │
│  └───────────────────────────────────────┘  │
│                     │                       │
└─────────────────────┼───────────────────────┘
                      │
                 WebSocket
                      │
                      ▼
┌─────────────────────────────────────────────┐
│              Reactotron Desktop             │
│                                             │
│  Timeline                                   │
│  ├── SET                                    │
│  ├── UPDATE                                 │
│  ├── DELETE                                 │
│  └── CLEAR_ALL                              │
│                                             │
│  State                                      │
│  ├── Redux                                  │
│  └── MMKV                                   │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The important part is that we didn't modify MMKV itself.

We added an observability layer around it.


12. Using the Plugin

Installation is intentionally simple.

npm install --save-dev reactotron-plugin-mmkv
Enter fullscreen mode Exit fullscreen mode

Or:

yarn add -D reactotron-plugin-mmkv
Enter fullscreen mode Exit fullscreen mode

Then create your MMKV instance normally:

import { MMKV } from "react-native-mmkv";

const rawStorage = new MMKV({
  id: "mmkv.default",
});
Enter fullscreen mode Exit fullscreen mode

In development, wrap it with the plugin:

let LocalStorage = rawStorage;

if (__DEV__) {
  const { mmkvPlugin } = require("reactotron-plugin-mmkv");
  const Reactotron =
    require("reactotron-react-native").default;

  const { plugin, storage } = mmkvPlugin({
    storage: rawStorage,
    mode: "proxy",
  });

  Reactotron.use(plugin);

  LocalStorage = storage;
}

export { LocalStorage };
Enter fullscreen mode Exit fullscreen mode

Application code can then continue using:

LocalStorage.set("theme", "dark");

const theme = LocalStorage.getString("theme");
Enter fullscreen mode Exit fullscreen mode

The application doesn't need to know whether the storage instance is instrumented.


13. Proxy Mode vs Basic Mode

The plugin provides two approaches depending on how much instrumentation you need.

Basic Mode

Basic mode relies more heavily on MMKV's native change notifications.

It's useful when you want lightweight monitoring with minimal interception.

Proxy Mode

Proxy mode wraps the storage instance and intercepts operations at the JavaScript layer.

This enables richer information such as:

  • SET vs UPDATE
  • Previous values
  • Delete information
  • Operation-specific payloads
  • More detailed Reactotron timeline events

If your goal is full debugging visibility, Proxy Mode is the more powerful option.

If your goal is minimal instrumentation, Basic Mode may be more appropriate.


14. Keeping Production Builds Clean

Reactotron is a development tool.

It shouldn't become part of your production runtime.

That's why the integration is guarded with:

if (__DEV__) {
  // Reactotron + MMKV instrumentation
}
Enter fullscreen mode Exit fullscreen mode

In production, the application simply uses the original MMKV instance:

Development:

App → Proxy → MMKV → Reactotron

Production:

App → MMKV
Enter fullscreen mode Exit fullscreen mode

This keeps debugging concerns separated from the release runtime.

It's a small architectural decision, but an important one for performance-sensitive applications.


15. What We Ended Up With

The final debugging experience looks much closer to what we had with Redux.

Timeline

🟢 MMKV SET

"user_token"
→ "eyJhbGci..."
Enter fullscreen mode Exit fullscreen mode
🟡 MMKV UPDATE

"theme"
before: "light"
after:  "dark"
Enter fullscreen mode Exit fullscreen mode
🔴 MMKV DELETE

"session_id"
removed: "abc123"
Enter fullscreen mode Exit fullscreen mode
⚫ MMKV CLEAR_ALL

removed 8 key(s)
Enter fullscreen mode Exit fullscreen mode

State

State
├── auth
├── cart
├── user
└── mmkv
    ├── user_token
    ├── theme
    └── user_settings
        ├── volume
        ├── notifications
        └── profile
            └── name
Enter fullscreen mode Exit fullscreen mode

The important difference is that MMKV is no longer a black box during development.

We get the performance benefits of native, synchronous storage while retaining the visibility we expect from a modern debugging workflow.


16. The Bigger Lesson

The interesting part of this project wasn't MMKV itself.

It was the architecture around observability.

High-performance systems often hide useful information behind optimized layers.

That's a good thing for production.

But during development, those same optimizations can make debugging harder.

The solution isn't always to sacrifice performance.

Sometimes the better approach is to introduce a thin instrumentation layer that exists only during development.

In our case, that layer combines:

  • JavaScript Proxy
  • MMKV's native change notifications
  • Operation classification
  • Before/after state inspection
  • JSON parsing
  • Dynamic state-path resolution
  • Reactotron protocol interception
  • Peer state merging

Each piece solves a different problem.

Together, they turn MMKV from a fast but opaque storage layer into something developers can actually inspect.


Conclusion

Moving from AsyncStorage to MMKV gave us the performance characteristics we wanted.

But it also exposed an important engineering trade-off:

Fast storage is great. Fast storage that you can actually debug is better.

Instead of giving up Reactotron, we built a bridge between the two.

reactotron-plugin-mmkv brings MMKV operations into the Reactotron timeline, exposes MMKV data through the State tab, generates useful before/after information, and allows MMKV to coexist with Redux and other Reactotron state providers.

The result is a development experience where performance and observability don't have to compete.

Explore the project

📦 NPM: [reactotron-plugin-mmkv](https://www.npmjs.com/package/reactotron-plugin-mmkv)

💻 GitHub: [mdRehan991/reactotron-plugin-mmkv](https://github.com/mdRehan991/reactotron-plugin-mmkv)

If you're using MMKV in a React Native application and want better visibility into what's happening inside your persistent storage, give it a try.

And if you run into an edge case, open an issue or contribute to the project. The most interesting parts of developer tooling often come from problems that initially look too small to solve.

Top comments (0)