React Native MMKV with Zustand

We'll explore how to combine React Native MMKV, a lightning-fast key-value storage library, with Zustand, a minimalist state management solution. This combination provides both high-performance persistence and an elegant API for managing application state.

What is React Native MMKV?

MMKV is an open-source key-value storage framework developed by WeChat. React Native MMKV is a wrapper around this framework, providing an extremely fast alternative to AsyncStorage. Unlike AsyncStorage, which is asynchronous and JSON-based, MMKV operates synchronously and uses a memory-mapped file format, resulting in significantly better performance.

Key benefits of React Native MMKV include:

  • Speed: Multiple times faster than AsyncStorage
  • Synchronous API: No need for async/await or promises
  • Type Safety: Better TypeScript support
  • Small Size: Minimal impact on bundle size
  • Encryption Support: Secure storage capabilities

What is Zustand?

Zustand is a small, fast state management library for React applications. It provides a minimalist API that makes creating and consuming stores straightforward while avoiding the boilerplate associated with other state management solutions like Redux.

Key benefits of Zustand include:

  • Simplicity: Minimal boilerplate and straightforward API
  • Performance: Fine-grained updates that prevent unnecessary re-renders
  • Flexibility: Easily extensible with middleware
  • Small Size: Tiny footprint (less than 1KB)
  • Hooks-based: Modern React pattern alignment

Setting Up

Let's start by installing both libraries:

# Using npm
npm install zustand react-native-mmkv

# Using yarn
yarn add zustand react-native-mmkv

# Using expo
expo install zustand react-native-mmkv

Basic MMKV Setup

First, let's initialize MMKV:

// storage.ts
import { MMKV } from 'react-native-mmkv';

// Create a new instance of MMKV
export const storage = new MMKV({
  id: 'my-app-storage',
  // Optional encryption
  // encryptionKey: 'encryption-key'
});

Creating a Basic Zustand Store

Now, let's create a simple Zustand store:

// useStore.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

Persisting Zustand Store with MMKV

To combine the two libraries, we'll use Zustand's middleware pattern to create a persistence layer with MMKV:

// usePersistedStore.ts
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { storage } from './storage';

// Create a storage object that works with MMKV
const mmkvStorage = {
  getItem: (name: string) => {
    const value = storage.getString(name);
    return value ? Promise.resolve(value) : Promise.resolve(null);
  },
  setItem: (name: string, value: string) => {
    storage.set(name, value);
    return Promise.resolve(true);
  },
  removeItem: (name: string) => {
    storage.delete(name);
    return Promise.resolve();
  },
};

interface UserState {
  user: {
    id: string;
    name: string;
    email: string;
  } | null;
  setUser: (user: UserState['user']) => void;
  clearUser: () => void;
}

// Create a persisted store
export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      user: null,
      setUser: (user) => set({ user }),
      clearUser: () => set({ user: null }),
    }),
    {
      name: 'user-storage',
      storage: createJSONStorage(() => mmkvStorage),
    }
  )
);

Using the Store in a Component

Here's how to use our persisted store in a React Native component:

// UserProfile.tsx
import React from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import { useUserStore } from './usePersistedStore';

export const UserProfile = () => {
  const { user, setUser, clearUser } = useUserStore();

  const loginUser = () => {
    setUser({
      id: '123',
      name: 'John Doe',
      email: '[email protected]',
    });
  };

  return (
    <View style={styles.container}>
      {user ? (
        <>
          <Text style={styles.title}>Welcome, {user.name}!</Text>
          <Text style={styles.subtitle}>{user.email}</Text>
          <Button title="Logout" onPress={clearUser} />
        </>
      ) : (
        <>
          <Text style={styles.title}>Not logged in</Text>
          <Button title="Login" onPress={loginUser} />
        </>
      )}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  title: {
    fontSize: 20,
    fontWeight: 'bold',
    marginBottom: 10,
  },
  subtitle: {
    fontSize: 16,
    marginBottom: 20,
    color: '#666',
  },
});

Advanced Usage: Partial Persistence

Often, you only want to persist specific parts of your store. Here's how to selectively persist only certain state fields:

// useSettingsStore.ts
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { storage } from './storage';

const mmkvStorage = {
  getItem: (name: string) => {
    const value = storage.getString(name);
    return value ? Promise.resolve(value) : Promise.resolve(null);
  },
  setItem: (name: string, value: string) => {
    storage.set(name, value);
    return Promise.resolve(true);
  },
  removeItem: (name: string) => {
    storage.delete(name);
    return Promise.resolve();
  },
};

interface SettingsState {
  theme: 'light' | 'dark';
  notifications: boolean;
  language: string;
  // Non-persisted state
  isLoading: boolean;
  setTheme: (theme: 'light' | 'dark') => void;
  toggleNotifications: () => void;
  setLanguage: (lang: string) => void;
  setLoading: (loading: boolean) => void;
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      notifications: true,
      language: 'en',
      isLoading: false,
      setTheme: (theme) => set({ theme }),
      toggleNotifications: () => set((state) => ({ notifications: !state.notifications })),
      setLanguage: (language) => set({ language }),
      setLoading: (isLoading) => set({ isLoading }),
    }),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => mmkvStorage),
      // Only persist these fields
      partialize: (state) => ({
        theme: state.theme,
        notifications: state.notifications,
        language: state.language,
      }),
    }
  )
);

Performance Optimization with State Selectors

Zustand encourages the use of selectors to prevent unnecessary re-renders. Here's how to optimize your components:

// SettingsScreen.tsx
import React from 'react';
import { View, Text, Switch, StyleSheet } from 'react-native';
import { useSettingsStore } from './useSettingsStore';

// Using selectors for performance
export const SettingsScreen = () => {
  // Only re-render when these specific values change
  const theme = useSettingsStore((state) => state.theme);
  const notifications = useSettingsStore((state) => state.notifications);
  const language = useSettingsStore((state) => state.language);
  
  const setTheme = useSettingsStore((state) => state.setTheme);
  const toggleNotifications = useSettingsStore((state) => state.toggleNotifications);

  return (
    <View style={[styles.container, { backgroundColor: theme === 'dark' ? '#222' : '#fff' }]}>
      <Text style={[styles.title, { color: theme === 'dark' ? '#fff' : '#000' }]}>
        Settings
      </Text>
      
      <View style={styles.setting}>
        <Text style={[styles.label, { color: theme === 'dark' ? '#fff' : '#000' }]}>
          Dark Mode
        </Text>
        <Switch
          value={theme === 'dark'}
          onValueChange={(value) => setTheme(value ? 'dark' : 'light')}
        />
      </View>
      
      <View style={styles.setting}>
        <Text style={[styles.label, { color: theme === 'dark' ? '#fff' : '#000' }]}>
          Notifications
        </Text>
        <Switch value={notifications} onValueChange={toggleNotifications} />
      </View>
      
      <Text style={[styles.footer, { color: theme === 'dark' ? '#ccc' : '#666' }]}>
        Language: {language}
      </Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 20,
  },
  setting: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    paddingVertical: 15,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  label: {
    fontSize: 16,
  },
  footer: {
    marginTop: 20,
    fontSize: 14,
  },
});

Handling Migrations and Versioning

As your app evolves, you may need to migrate your stored data. Zustand's persist middleware supports versioning:

// useAppStore.ts with migration support
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { storage } from './storage';

const mmkvStorage = {
  getItem: (name: string) => {
    const value = storage.getString(name);
    return value ? Promise.resolve(value) : Promise.resolve(null);
  },
  setItem: (name: string, value: string) => {
    storage.set(name, value);
    return Promise.resolve(true);
  },
  removeItem: (name: string) => {
    storage.delete(name);
    return Promise.resolve();
  },
};

interface TodoItem {
  id: string;
  text: string;
  completed: boolean;
}

interface AppState {
  todos: TodoItem[];
  version: number;
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  removeTodo: (id: string) => void;
}

export const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      todos: [],
      version: 2, // Current version of the schema
      addTodo: (text) =>
        set((state) => ({
          todos: [
            ...state.todos,
            { id: Date.now().toString(), text, completed: false },
          ],
        })),
      toggleTodo: (id) =>
        set((state) => ({
          todos: state.todos.map((todo) =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
          ),
        })),
      removeTodo: (id) =>
        set((state) => ({
          todos: state.todos.filter((todo) => todo.id !== id),
        })),
    }),
    {
      name: 'app-storage',
      storage: createJSONStorage(() => mmkvStorage),
      version: 2,
      migrate: (persistedState, version) => {
        let state = persistedState as any;
        
        // If migrating from version 1 to version 2
        if (version === 1) {
          // For example, add a new field to each todo
          return {
            ...state,
            todos: state.todos.map((todo: any) => ({
              ...todo,
              priority: 'normal', // New field added in version 2
            })),
            version: 2,
          };
        }
        
        return {
          ...state,
          version: 2,
        };
      },
    }
  )
);

Working with Complex Data Types

MMKV works well with primitive types and JSON, but sometimes you need to store more complex data:

// useComplexDataStore.ts
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { storage } from './storage';

const mmkvStorage = {
  getItem: (name: string) => {
    const value = storage.getString(name);
    return value ? Promise.resolve(value) : Promise.resolve(null);
  },
  setItem: (name: string, value: string) => {
    storage.set(name, value);
    return Promise.resolve(true);
  },
  removeItem: (name: string) => {
    storage.delete(name);
    return Promise.resolve();
  },
};

// For dates and other complex types
const serializeDate = (date: Date) => date.toISOString();
const deserializeDate = (dateString: string) => new Date(dateString);

interface AppointmentData {
  id: string;
  title: string;
  date: Date;
  notes: string;
}

interface AppointmentState {
  appointments: AppointmentData[];
  addAppointment: (appointment: Omit<AppointmentData, 'id'>) => void;
  removeAppointment: (id: string) => void;
}

export const useAppointmentStore = create<AppointmentState>()(
  persist(
    (set) => ({
      appointments: [],
      addAppointment: (appointmentData) =>
        set((state) => ({
          appointments: [
            ...state.appointments,
            { ...appointmentData, id: Date.now().toString() },
          ],
        })),
      removeAppointment: (id) =>
        set((state) => ({
          appointments: state.appointments.filter((appt) => appt.id !== id),
        })),
    }),
    {
      name: 'appointment-storage',
      storage: createJSONStorage(() => mmkvStorage),
      // Custom serialization/deserialization for complex types
      serialize: (state) => {
        return JSON.stringify({
          ...state,
          appointments: state.appointments.map((appt) => ({
            ...appt,
            date: serializeDate(appt.date),
          })),
        });
      },
      deserialize: (str) => {
        const state = JSON.parse(str);
        return {
          ...state,
          appointments: state.appointments.map((appt: any) => ({
            ...appt,
            date: deserializeDate(appt.date),
          })),
        };
      },
    }
  )
);

Handling Multiple Stores

In larger applications, you might want to split your state across multiple stores:

// stores/index.ts
import { useUserStore } from './useUserStore';
import { useSettingsStore } from './useSettingsStore';
import { useAppStore } from './useAppStore';
import { useAppointmentStore } from './useAppointmentStore';

// Export all stores
export { useUserStore, useSettingsStore, useAppStore, useAppointmentStore };

// Helper hook to reset all stores on logout
export const resetAllStores = () => {
  useUserStore.getState().clearUser();
  useAppStore.getState().clearData();
  // Keep settings unless you want to reset those too
  // useSettingsStore.getState().resetToDefaults();
};

Performance Considerations

While MMKV is incredibly fast, there are some best practices to follow:

  1. Avoid storing large objects: Break them down into smaller stores if possible
  2. Use selectors: This prevents unnecessary re-renders
  3. Debounce frequent updates: If state changes rapidly, consider debouncing persistence operations
  4. Separate UI state: Not all state needs to be persisted

Here's an example of debouncing:

// useDebouncePersistedStore.ts
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { storage } from './storage';
import debounce from 'lodash.debounce';

const mmkvStorage = {
  getItem: (name: string) => {
    const value = storage.getString(name);
    return value ? Promise.resolve(value) : Promise.resolve(null);
  },
  setItem: (name: string, value: string) => {
    storage.set(name, value);
    return Promise.resolve(true);
  },
  removeItem: (name: string) => {
    storage.delete(name);
    return Promise.resolve();
  },
};

// Custom storage with debounced writes
const createDebouncedStorage = (storage: any, delay = 1000) => {
  const debouncedSetItem = debounce(storage.setItem, delay);
  
  return {
    ...storage,
    setItem: debouncedSetItem,
  };
};

interface EditorState {
  content: string;
  cursorPosition: number;
  updateContent: (content: string) => void;
  updateCursorPosition: (position: number) => void;
}

export const useEditorStore = create<EditorState>()(
  persist(
    (set) => ({
      content: '',
      cursorPosition: 0,
      updateContent: (content) => set({ content }),
      updateCursorPosition: (cursorPosition) => set({ cursorPosition }),
    }),
    {
      name: 'editor-storage',
      storage: createJSONStorage(() => createDebouncedStorage(mmkvStorage)),
    }
  )
);

Security Considerations

MMKV supports encryption which makes it suitable for storing sensitive data:

// secureStorage.ts
import { MMKV } from 'react-native-mmkv';
import { Platform } from 'react-native';
import * as Crypto from 'expo-crypto';

const generateEncryptionKey = async () => {
  const randomBytes = await Crypto.getRandomBytesAsync(32);
  return [...new Uint8Array(randomBytes)]
    .map(byte => byte.toString(16).padStart(2, '0'))
    .join('');
};

// Initialize with encryption
export const initializeSecureStorage = async () => {
  // In a real app, you might store this key securely using Keychain/Keystore
  let encryptionKey = await generateEncryptionKey();
  
  const secureStorage = new MMKV({
    id: 'secure-storage',
    encryptionKey,
  });
  
  return secureStorage;
};

Conclusion

Combining React Native MMKV with Zustand provides a powerful, high-performance state management solution for your React Native applications. The synchronous nature of MMKV paired with Zustand's simple API creates a developer-friendly experience without sacrificing performance.

This combination is particularly beneficial for:

  1. Applications that need offline capabilities: Instantly save and retrieve state
  2. Performance-critical apps: The high-speed nature of MMKV makes it perfect for smooth user experiences
  3. Apps with complex state: Zustand's middleware system allows for easy state composition

By following the patterns outlined in this guide, you can create applications that are both responsive and maintain state across app launches.

Keep in mind that while MMKV is excellent for most use cases, for very sensitive information you might want to consider additional security measures such as React Native Keychain for storing encryption keys.