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()
A post-write listener can tell us that something changed.
But Reactotron needs much more useful information:
SET
UPDATE
DELETE
CLEAR_ALL
And ideally:
theme
before: "light"
after: "dark"
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
The application continues using the familiar MMKV API.
For example:
storage.set("theme", "dark");
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");
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
So if the key didn't exist:
🟢 MMKV SET
theme → "dark"
If the key already contained "light":
🟡 MMKV UPDATE
theme
before: "light"
after: "dark"
And for deletion:
🔴 MMKV DELETE
theme
removed: "dark"
This is significantly more useful than simply receiving:
theme changed
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
This approach also lets the plugin optionally observe reads.
For example:
storage.getString("theme");
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"
]
});
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
But applications often store serialized JSON:
storage.set(
"user_settings",
JSON.stringify({
volume: 80,
notifications: true,
profile: {
name: "Alex"
}
})
);
If Reactotron simply displayed the raw value, developers would see:
user_settings:
"{\"volume\":80,\"notifications\":true,\"profile\":{\"name\":\"Alex\"}}"
That's technically correct.
But it's not a great debugging experience.
We wanted:
user_settings
├── volume: 80
├── notifications: true
└── profile
└── name: "Alex"
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
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}"
Reactotron can display:
"user_settings": {
"volume": 80
}
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
Both plugins can potentially respond to Reactotron's state requests.
And that creates a collision.
Imagine Reactotron asks:
state.values.request
path: null
The Redux plugin responds with:
{
auth: {...},
user: {...},
cart: {...}
}
The MMKV plugin responds with:
{
mmkv: {
theme: "dark",
token: "...",
}
}
If those responses aren't coordinated, one can effectively overwrite the other.
Instead of seeing:
State
├── auth
├── user
├── cart
└── mmkv
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
The resulting tree becomes:
State
├── auth
├── user
├── cart
└── mmkv
├── user_token
├── theme
└── user_settings
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
This allows the plugin to work in both environments:
Redux + MMKV
and:
MMKV only
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 │
└─────────────────────────────────────────────┘
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
Or:
yarn add -D reactotron-plugin-mmkv
Then create your MMKV instance normally:
import { MMKV } from "react-native-mmkv";
const rawStorage = new MMKV({
id: "mmkv.default",
});
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 };
Application code can then continue using:
LocalStorage.set("theme", "dark");
const theme = LocalStorage.getString("theme");
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
}
In production, the application simply uses the original MMKV instance:
Development:
App → Proxy → MMKV → Reactotron
Production:
App → MMKV
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..."
🟡 MMKV UPDATE
"theme"
before: "light"
after: "dark"
🔴 MMKV DELETE
"session_id"
removed: "abc123"
⚫ MMKV CLEAR_ALL
removed 8 key(s)
State
State
├── auth
├── cart
├── user
└── mmkv
├── user_token
├── theme
└── user_settings
├── volume
├── notifications
└── profile
└── name
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)