On this page
- What You Need Before Writing a Single Line
- A Filing Cabinet With Self-Organizing Drawers
- Building the Store: MMKV Instance, Read, Write, Create
- Setting Up the Storage Namespace
- The Read/Write Pair
- Creating and Updating Sessions
- Why Synchronous Storage Is Worth the Trade-off
- What Happens When Things Go Wrong
AsyncStorage has a friendly interface. You call setItem, you call getItem, everything works in isolation. Then users start navigating between screens and the chat history flickers, or shows stale data for a half-second before snapping to the right content. The culprit is the architecture: every read and write crosses the JavaScript-to-native bridge asynchronously. During a navigation transition, when React is in the middle of reconciliation, that latency is visible and jarring.
For a mindfulness app that’s supposed to feel calm, a stuttering history screen is a product failure.
react-native-mmkv solves this by using JSI (JavaScript Interface) — a newer React Native architecture feature that lets JavaScript call C++ directly, skipping the async bridge entirely. Reads are synchronous, fast enough to use mid-navigation transition. This tutorial walks through services/storage/ChatHistoryStore.ts, the class that manages the full lifecycle of chat sessions using this approach.
What You Need Before Writing a Single Line
Knowledge:
- TypeScript classes and access modifiers
- JSON serialization (
JSON.stringify/JSON.parse) - JavaScript
Dateobjects and ISO 8601 strings - Basic React Native concepts (what “the bridge” means)
Environment:
react-native-mmkv ^4.0.0
react-native 0.81.5
expo ~54.0.22
typescript ~5.9.2
A Filing Cabinet With Self-Organizing Drawers
The store manages two distinct layers of data. Think of MMKV as a fireproof filing cabinet bolted to the floor. The sessions key is the master index card listing every folder. The current_session_id key is a sticky note on the cabinet door that says “currently open: folder #7.” The ChatHistoryStore class is the filing clerk — it knows the cabinet’s layout and handles all reads and writes so nothing else in the app has to.
When you call getSessions(), the clerk pulls the index card and hands you a typed list. When you call createSession(), the clerk updates the index card and puts it back. The cabinet never goes offline.
Building the Store: MMKV Instance, Read, Write, Create
Setting Up the Storage Namespace
import { createMMKV } from 'react-native-mmkv';
export class ChatHistoryStore {
private storage: ReturnType<typeof createMMKV>;
private sessionsKey = 'chat_sessions';
constructor() {
this.storage = createMMKV({
id: 'mindfulness-coach-storage',
});
}
}
The id gives this MMKV instance its own isolated namespace. Using the same id across app restarts gives persistence. One thing to watch out for: if you create two MMKV instances with the same id in different parts of your app, they share the same underlying storage file. This is intentional when MessageStore uses the same id as ChatHistoryStore, but accidental id collisions between unrelated stores will cause data corruption.
The Read/Write Pair
All storage operations reduce to two private methods: read the sessions array, mutate it, write it back.
getSessions(): ChatSession[] {
try {
const serialized = this.storage.getString(this.sessionsKey);
if (!serialized) return [];
const sessions = JSON.parse(serialized);
// JSON.parse turns Date strings back into plain strings.
// We must manually re-hydrate them as Date objects.
return sessions.map((s: any) => ({
...s,
createdAt: new Date(s.createdAt),
updatedAt: new Date(s.updatedAt),
}));
} catch (error) {
console.error('Failed to get sessions:', error);
return [];
}
}
private saveSessions(sessions: ChatSession[]): void {
try {
const serialized = JSON.stringify(sessions.map(s => ({
...s,
createdAt: s.createdAt.toISOString(),
updatedAt: s.updatedAt.toISOString(),
})));
this.storage.set(this.sessionsKey, serialized);
} catch (error) {
console.error('Failed to save sessions:', error);
}
}
The Date re-hydration in getSessions() is the thing most people miss. JSON.parse has no idea that a string like "2026-03-31T14:22:00.000Z" should be a Date object — it comes back as a plain string. TypeScript won’t catch this at compile time if you type the parsed JSON as any. The type system trusts your assertion. So when session.createdAt.toLocaleDateString() throws a TypeError saying “string has no method toLocaleDateString”, you’ve hit this bug. Always re-hydrate Date fields explicitly in getSessions().
The saveSessions() method is private — only called internally after mutations. Nothing outside the class should write to storage directly.
Creating and Updating Sessions
Every mutation method follows the same pattern: read, mutate in memory, write back.
createSession(sessionId: string, firstMessage?: string): ChatSession {
const sessions = this.getSessions(); // 1. Read
const newSession: ChatSession = {
id: sessionId,
title: this.generateTitle(firstMessage),
createdAt: new Date(),
updatedAt: new Date(),
messageCount: 0,
preview: firstMessage,
};
sessions.push(newSession); // 2. Mutate
this.saveSessions(sessions); // 3. Persist
return newSession;
}
updateSession(sessionId: string, updates: Partial<ChatSession>): void {
const sessions = this.getSessions();
const index = sessions.findIndex(s => s.id === sessionId);
if (index >= 0) {
sessions[index] = {
...sessions[index],
...updates,
updatedAt: new Date(), // Always refresh the timestamp
};
this.saveSessions(sessions);
}
}
Why Synchronous Storage Is Worth the Trade-off
When AsyncStorage.getItem() runs, execution leaves the V8 JavaScript engine, crosses the bridge to the native side, waits for disk I/O, and then posts a callback back to the JS thread. During a navigation transition, React is mid-reconciliation. The bridge callback lands on a busy event loop. The UI stutters.
react-native-mmkv’s JSI integration means storage.getString() executes as a synchronous C++ function call from within V8 itself — no bridge crossing, no callback, no event loop queuing. The underlying storage format is memory-mapped (the same technique used by SQLite’s WAL mode), so frequently read keys are often served from the OS page cache without a disk seek.
The trade-off is real: synchronous storage blocks the JS thread for the duration of the call. For small payloads — session metadata, settings strings — this is microseconds. For large payloads like thousands of messages, you’d want a batched-write pattern instead.
Operation complexity for this implementation, where n is the number of sessions:
| Operation | Complexity |
|---|---|
getSessions() | O(n) — deserializes all sessions |
createSession() | O(n) — reads all, appends one, writes all |
deleteSession() | O(n) — reads all, filters, writes all |
getSession(id) | O(n) — linear scan after deserialization |
For a typical user with fewer than 100 sessions, O(n) is negligible. At scale you’d replace the array with an indexed structure.
What Happens When Things Go Wrong
Corrupt data. If the app crashes mid-write, MMKV’s mmap format means the write either fully committed or fully didn’t — there’s no half-written state (unlike plain file I/O). The try/catch in getSessions() handles the case where previously valid data becomes unparseable after an OS-level storage event. It returns an empty array rather than crashing, which is the right degradation for a non-critical history store.
Concurrent access. React Native runs JavaScript on a single thread. Two calls to createSession() cannot genuinely interleave. If you ever move storage writes to a native background thread (which MMKV supports), you’d need locking around the read-modify-write cycle.
The synchronous store pattern here applies beyond chat sessions — settings, user profiles, feature flags, session indexes, any bounded dataset you need to read during UI transitions. The key insight is that the right storage primitive depends on when you need the data. If it’s during a render or navigation, synchronous is worth it.