featured image

Your App Works — Until the User Closes It: Persisting Chat Sessions Without AsyncStorage

A deep dive into the design and implementation of a synchronous, serialization-safe session store in React Native using `react-native-mmkv`, enabling fast, offline-first persistence for a mindfulness coaching app without the jank of AsyncStorage.

Published

Thu Oct 30 2025

Technologies Used

React Native Typescript MMKV
Beginner 15 minutes

Purpose

The Problem

The default React Native storage API — AsyncStorage — has a deceptively friendly interface. You call setItem, you call getItem, everything seems fine. Then your users open the app on a navigation transition and the chat history screen flickers, lags, or shows stale data. The culprit is AsyncStorage’s architecture: every read and write crosses the JavaScript-to-native bridge asynchronously. During UI navigation, that latency is visible and jarring.

For a mindfulness app that is supposed to feel calm, a stuttering history list is a product failure.

The Solution

We’ll analyze services/storage/ChatHistoryStore.ts — a class that manages the full lifecycle of chat sessions (create, read, update, delete) using react-native-mmkv, a synchronous C++-backed key-value store. We’ll build the same store from scratch, understanding each decision along the way.

By the end, you’ll know how to design a synchronous, serialization-safe storage layer in React Native without AsyncStorage, external databases, or network calls.

What You Need Before Writing a Single Line: MMKV, TypeScript, and the Class Pattern

Knowledge Base:

  • TypeScript classes and access modifiers (private, public)
  • JSON serialization (JSON.stringify / JSON.parse)
  • JavaScript Date objects and ISO 8601 strings
  • Basic React Native concepts (what “the bridge” means)

Environment (from package.json):

react-native-mmkv       ^4.0.0
react-native            0.81.5
expo                    ~54.0.22
typescript              ~5.9.2

🔵 Deep Dive: react-native-mmkv uses JSI (JavaScript Interface) — a newer React Native architecture feature that lets JavaScript call C++ directly, bypassing the async bridge entirely. This is what makes reads synchronous and fast enough to use during UI transitions.

The Filing Cabinet Model: How a Session Store Thinks About Data

The store manages two distinct layers of data that mirror a physical filing cabinet:

graph TD
    subgraph MMKV Storage
        SK[chat_sessions key] --> SL["[Session[], serialized as JSON]"]
        CK[current_session_id key] --> CID[string UUID]
    end

    subgraph In-Memory
        CS[ChatHistoryStore class] -->|getSessions| SK
        CS -->|saveSessions| SK
        CS -->|getCurrentSessionId| CK
        CS -->|setCurrentSessionId| CK
    end

    subgraph Callers
        HOOK[useChatHistory hook] --> CS
        SCREEN[ChatScreen] --> HOOK
        HISTORY[ChatHistoryScreen] --> HOOK
    end

Analogy: 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, reads it, 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.

From Raw Strings to Typed Sessions: Writing the Store Step by Step

Step 1: Setting Up the MMKV Instance

Before storing anything, we need to create a named MMKV instance. Each instance gets its own isolated storage namespace.

import { createMMKV } from 'react-native-mmkv';

export class ChatHistoryStore {
  // The storage instance — one per class, shared across all method calls
  private storage: ReturnType<typeof createMMKV>;
  
  // A fixed key that acts as the index for all sessions
  private sessionsKey = 'chat_sessions';

  constructor() {
    // 'id' gives this MMKV instance its own namespace.
    // Using the same id across app restarts gives persistence.
    this.storage = createMMKV({
      id: 'mindfulness-coach-storage',
    });
  }
}

🔴 Danger: 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 here (MessageStore uses the same id), but accidental id collisions between unrelated stores will cause data corruption.

Step 2: The Read/Write Pair — The Core of Every Store

All storage operations boil down to two private methods: read the sessions array, mutate it, write it back.

// PUBLIC: Used by hooks and screens
getSessions(): ChatSession[] {
  try {
    // storage.getString returns undefined if the key doesn't exist
    const serialized = this.storage.getString(this.sessionsKey);
    if (!serialized) return [];
    
    const sessions = JSON.parse(serialized);
    
    // CRITICAL: 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) {
    // Corrupt data should not crash the app — return empty and recover.
    console.error('Failed to get sessions:', error);
    return [];
  }
}

// PRIVATE: Only called internally after mutations
private saveSessions(sessions: ChatSession[]): void {
  try {
    // Convert Date objects back to ISO strings before storing.
    // JSON.stringify does NOT call .toISOString() automatically.
    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);
  }
}

Step 3: Creating and Updating Sessions

With read and write established, every other method is just “get → mutate → save”:

createSession(sessionId: string, firstMessage?: string): ChatSession {
  const sessions = this.getSessions(); // 1. Read current state
  
  const newSession: ChatSession = {
    id: sessionId,
    title: this.generateTitle(firstMessage), // Auto-title from first message
    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(); // 1. Read
  const index = sessions.findIndex(s => s.id === sessionId);
  
  if (index >= 0) {
    // Spread operator merges updates while preserving unchanged fields
    sessions[index] = {
      ...sessions[index],
      ...updates,
      updatedAt: new Date(), // Always refresh the timestamp
    };
    this.saveSessions(sessions); // 3. Persist (step 2 was inline above)
  }
}

Why Synchronous Reads Are Worth the Trade-off: V8, the Bridge, and Cache Lines

🔵 Deep Dive: When AsyncStorage.getItem() is called, execution leaves the V8 JavaScript engine, crosses the bridge to the native side, waits for a disk I/O operation, and then posts a callback back to the JS thread. During UI transitions, React is in the middle of a reconciliation pass. That bridge callback lands on a busy event loop, and 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 directly from the OS page cache without a disk seek at all.

The trade-off: synchronous storage blocks the JS thread for the duration of the call. For small payloads (a list of session metadata, a settings string), this is microseconds. For large payloads (thousands of messages), you’d want MessageStore’s batched-write pattern instead.

Big-O for this implementation:

OperationComplexity
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.

When the Filing Cabinet Catches Fire: Serialization Failures and Corrupt State

Serialization failure: If the app crashes mid-write, MMKV’s underlying mmap format means the write either fully committed or fully didn’t — there is no half-written state (unlike plain file I/O). The try/catch in getSessions() handles the case where previously written data was valid JSON but is now unparseable after an OS-level storage event.

Date deserialization trap: This is the most common bug in stores like this. Consider:

// What comes back from JSON.parse:
{ createdAt: "2026-03-31T14:22:00.000Z" } // type: string

// What the code does:
{ createdAt: new Date("2026-03-31T14:22:00.000Z") } // type: Date ✓

// What happens if you forget the re-hydration step:
session.createdAt.toLocaleDateString() // 💥 TypeError: string has no method toLocaleDateString

🔴 Danger: TypeScript will not catch this at compile time if you type the parsed JSON as any. The type system trusts your assertion. Always re-hydrate Date fields explicitly in getSessions() — never assume JSON.parse produces the right types.

Concurrent write scenario: React Native runs JavaScript on a single thread. There is no true concurrency problem here — two calls to createSession() cannot interleave. However, if you ever move storage writes to a native background thread (which MMKV supports), you would need to add locking around the read-modify-write cycle.

You Can Now Build Storage Layers That Don’t Flicker: The Synchronous Store Pattern

You’ve learned how to implement the synchronous read-modify-write store pattern using react-native-mmkv. Specifically:

  • How to create isolated MMKV instances with named IDs
  • Why Date objects require explicit re-hydration after JSON.parse
  • The read → mutate → write cycle that underlies every mutation method
  • Why synchronous storage is appropriate for small payloads accessed during UI transitions
  • How to write defensive error handling that degrades gracefully rather than crashing

This pattern applies to any React Native app that needs fast, offline-first persistence for bounded datasets: settings, user profiles, feature flags, or session indexes.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!