Storage Offline in React Native nel 2026: MMKV, Expo SQLite, WatermelonDB e TanStack Query a Confronto

Confronto pratico tra AsyncStorage, MMKV, Expo SQLite, op-sqlite e WatermelonDB per lo storage offline in React Native nel 2026. Benchmark reali, esempi di codice, integrazione con Zustand/Jotai e TanStack Query, e guida alla scelta per ogni caso d'uso.

Storage Offline React Native: MMKV vs SQLite

Perché lo Storage Offline È Fondamentale nelle App Mobile

Se hai mai aperto un'app in metro e ti sei trovato davanti a una schermata bianca, sai già di cosa sto parlando. I dati non mentono: il 94% degli utenti abbandona un'app che non funziona senza connessione. E nel 2026, con le app mobile che gestiscono sempre più dati locali — dalle preferenze utente ai feed complessi — scegliere la giusta strategia di persistenza non è più opzionale. È una decisione architetturale critica.

Il problema? L'ecosistema React Native offre almeno cinque librerie serie per lo storage locale, ognuna con filosofia, prestazioni e casi d'uso diversi. AsyncStorage, MMKV, Expo SQLite, op-sqlite, WatermelonDB — e poi ci sono pattern come TanStack Query con persistenza che cambiano completamente l'approccio. Orientarsi non è banale.

Allora, facciamo ordine. In questa guida le mettiamo tutte a confronto con benchmark reali, codice pronto all'uso e una mappa decisionale chiara. Niente teoria astratta: solo quello che ti serve per scegliere.

AsyncStorage: il Default che Devi Superare

@react-native-async-storage/async-storage v3.0.2 è la libreria di storage più usata nell'ecosistema React Native, con oltre 2,5 milioni di download settimanali su npm. Funziona su tutte le piattaforme (Android, iOS, macOS, Web) ed è inclusa di default in Expo. La conosci già, probabilmente.

Quando Va Bene AsyncStorage

Per configurazioni semplici — un tema scuro, una lingua preferita, un flag di onboarding completato — AsyncStorage è perfettamente adeguato. Zero configurazione, API familiare, funziona ovunque incluso Expo Go.

import AsyncStorage from '@react-native-async-storage/async-storage';

// Salva una preferenza
await AsyncStorage.setItem('theme', 'dark');
await AsyncStorage.setItem('user_prefs', JSON.stringify({
  language: 'it',
  notifications: true,
}));

// Leggi
const theme = await AsyncStorage.getItem('theme');
const prefs = JSON.parse(await AsyncStorage.getItem('user_prefs') ?? '{}')

I Limiti Reali di AsyncStorage

Ecco dove le cose si complicano (e dove molti si scottano in produzione):

  • 20-30x più lento di MMKV nelle operazioni di lettura e scrittura
  • Limite di ~6 MB su iOS prima che le prestazioni degradino seriamente
  • Nessuna crittografia: i dati sono salvati in chiaro nel filesystem
  • Solo API asincrona: ogni operazione richiede await, niente accesso sincrono
  • Nessuna query: niente indici, ordinamento o ricerca — solo chiave-valore
  • Blocca il thread JS: operazioni frequenti o grandi possono causare lag nell'UI

Il backend di AsyncStorage è SQLite sia su Android che su iOS, ma l'overhead dell'API asincrona basata su bridge lo rende significativamente più lento delle alternative native. Onestamente, per qualsiasi cosa oltre alle semplici preferenze, è il momento di passare a qualcosa di meglio.

MMKV: Storage Key-Value Ultraveloce

react-native-mmkv v4.3.0 (marzo 2026) è una riscrittura completa come Nitro Module, basata sulla libreria MMKV sviluppata originariamente da WeChat per gestire miliardi di operazioni al giorno. I risultati parlano da soli — e non esagero.

Benchmark: MMKV vs AsyncStorage

Test su iPhone 11 Pro, 1.000 operazioni di lettura:

OperazioneAsyncStorageMMKVVelocità
Lettura2,548 ms0,520 ms~5x più veloce
Scrittura2,871 ms0,570 ms~5x più veloce

In scenari più complessi il vantaggio arriva fino a 20-30x. La ragione è semplice: MMKV usa memory-mapped files con accesso diretto tramite JSI, saltando completamente il bridge e il thread JavaScript per le operazioni native. Questo fa tutta la differenza quando l'app cresce.

Installazione e Configurazione

# Con Expo (consigliato)
npx expo install react-native-mmkv react-native-nitro-modules
npx expo prebuild

# Con React Native CLI
npm install react-native-mmkv react-native-nitro-modules
cd ios && pod install

Requisiti: React Native >= 0.74, Nuova Architettura / TurboModules abilitata.

API Completa con Esempi

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

// Crea un'istanza con configurazione
const storage = createMMKV({
  id: 'app.storage',            // ID univoco dell'istanza
  encryptionKey: 'la-tua-chiave', // Crittografia AES opzionale
  encryptionType: 'AES-256',    // AES-128 (default) o AES-256
  compareBeforeSet: true,        // Salta la scrittura se il valore non cambia (v4.3.0+)
});

// Scrittura — supporta string, number, boolean, ArrayBuffer
storage.set('user.name', 'Marco Rossi');
storage.set('user.age', 32);
storage.set('user.premium', true);

// Lettura tipizzata
const name = storage.getString('user.name');     // string | undefined
const age = storage.getNumber('user.age');        // number | undefined
const premium = storage.getBoolean('user.premium'); // boolean | undefined

// Gestione chiavi
storage.contains('user.name');   // true
storage.getAllKeys();             // ['user.name', 'user.age', 'user.premium']
storage.remove('user.age');      // rimuove una chiave
storage.clearAll();              // pulisce tutto

// Dimensione storage
console.log(storage.size);       // dimensione in byte

React Hooks Integrati

MMKV v4 include hook React basati su useSyncExternalStore() che si aggiornano automaticamente quando il valore cambia — anche da altre parti dell'app o da altri processi. Questo è uno degli aspetti che preferisco di questa libreria:

import { useMMKVString, useMMKVNumber, useMMKVBoolean } from 'react-native-mmkv';

function UserProfile() {
  const [username, setUsername] = useMMKVString('user.name');
  const [age, setAge] = useMMKVNumber('user.age');
  const [isPremium, setIsPremium] = useMMKVBoolean('user.premium');

  return (
    <View>
      <Text>{username ?? 'Ospite'}</Text>
      <Button
        title="Aggiorna nome"
        onPress={() => setUsername('Nuovo Nome')}
      />
    </View>
  );
}

Crittografia AES-256

MMKV supporta crittografia AES-128 e AES-256 sia alla creazione che a runtime:

// Crittografia alla creazione
const secureStorage = createMMKV({
  id: 'secure.storage',
  encryptionKey: 'chiave-da-keychain',
  encryptionType: 'AES-256',
});

// Crittografia a runtime (v4.2.0+)
storage.encrypt('nuova-chiave', 'AES-256');

// Controlla stato
console.log(storage.isEncrypted); // true

// Rimuovi crittografia
storage.decrypt();

Best practice: non hardcodare mai la chiave di crittografia nel codice. Recuperala dal Keychain (iOS) o dal Keystore (Android) usando expo-secure-store o react-native-keychain. Sembra ovvio, ma lo vedo succedere ancora troppo spesso.

Integrazione con Zustand

Uno dei pattern più diffusi nel 2026 è usare MMKV come backend di persistenza per Zustand. Se stai già usando Zustand per gestire lo stato, questa integrazione è quasi obbligata:

import { create } from 'zustand';
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
import { createMMKV } from 'react-native-mmkv';

const storage = createMMKV({ id: 'zustand.persist' });

const zustandStorage: StateStorage = {
  setItem: (name, value) => { storage.set(name, value); },
  getItem: (name) => storage.getString(name) ?? null,
  removeItem: (name) => { storage.remove(name); },
};

interface AuthStore {
  token: string | null;
  setToken: (token: string | null) => void;
}

const useAuthStore = create<AuthStore>()(
  persist(
    (set) => ({
      token: null,
      setToken: (token) => set({ token }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => zustandStorage),
    }
  )
);

Integrazione con Jotai

import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { createMMKV } from 'react-native-mmkv';

const mmkv = createMMKV({ id: 'jotai.persist' });

const mmkvStorage = createJSONStorage<any>(() => ({
  getItem: (key: string) => mmkv.getString(key) ?? null,
  setItem: (key: string, value: string) => mmkv.set(key, value),
  removeItem: (key: string) => mmkv.remove(key),
}));

// Atom persistente — il valore sopravvive al riavvio dell'app
export const themeAtom = atomWithStorage('theme', 'light', mmkvStorage, {
  getOnInit: true,  // Leggi il valore salvato immediatamente
});

export const onboardingAtom = atomWithStorage('onboarding-done', false, mmkvStorage, {
  getOnInit: true,
});

Expo SQLite: Database Relazionale Integrato

Quando i dati hanno relazioni tra loro — utenti, ordini, prodotti, categorie — un semplice key-value store non basta più. Serve un database relazionale vero e proprio. Expo SQLite (parte di SDK 52+) è la soluzione integrata nell'ecosistema Expo, completamente riscritta per la Nuova Architettura.

Perché Expo SQLite nel 2026

  • API moderna: completamente basata su Promise e async/await (addio callback)
  • Tagged template literals: query SQL con interpolazione sicura dei parametri
  • API sincrona: metodi *Sync per quando serve accesso immediato
  • SQLCipher: crittografia del database integrata
  • libSQL/Turso: sincronizzazione con database remoti (SDK 53+)
  • KV-Store: drop-in replacement per AsyncStorage con API sincrona
  • DevTools: inspector integrato nel browser per debug

Setup e Configurazione

# Installazione
npx expo install expo-sqlite

Configura le opzioni nel file app.json:

{
  "expo": {
    "plugins": [
      ["expo-sqlite", {
        "enableFTS": true,
        "useSQLCipher": true,
        "useLibSQL": false
      }]
    ]
  }
}

API Fondamentali

import * as SQLite from 'expo-sqlite';

// Apri il database (asincrono)
const db = await SQLite.openDatabaseAsync('app.db');

// Crea tabelle
await db.execAsync(`
  CREATE TABLE IF NOT EXISTS products (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    price REAL NOT NULL,
    category TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  );
  CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);
`);

// Inserisci dati — runAsync restituisce { lastInsertRowId, changes }
const result = await db.runAsync(
  'INSERT INTO products (name, price, category) VALUES (?, ?, ?)',
  ['iPhone 16', 1099.99, 'electronics']
);
console.log('ID inserito:', result.lastInsertRowId);

// Query singola riga
const product = await db.getFirstAsync<{
  id: number; name: string; price: number;
}>('SELECT * FROM products WHERE id = ?', [result.lastInsertRowId]);

// Query tutte le righe
const electronics = await db.getAllAsync<{
  id: number; name: string; price: number;
}>('SELECT * FROM products WHERE category = ?', ['electronics']);

// Iterazione con cursore (per dataset grandi)
for await (const row of db.getEachAsync('SELECT * FROM products')) {
  console.log(row.name);
}

Tagged Template Literals (SDK 52+)

Questa è probabilmente la mia API preferita di Expo SQLite — la sintassi è elegante e i parametri vengono automaticamente sanitizzati, quindi niente più SQL injection accidentali:

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

// Query con tipo — restituisce Product[]
const minPrice = 50;
const category = 'electronics';
const products = await db.sql<Product>`
  SELECT * FROM products
  WHERE price > ${minPrice}
  AND category = ${category}
`;

// Singolo risultato
const cheapest = await db.sql<Product>`
  SELECT * FROM products
  ORDER BY price ASC
  LIMIT 1
`.first();

// Solo valori (senza nomi colonna)
const names = await db.sql`
  SELECT name FROM products
`.values();

Transazioni

// Transazione standard
await db.withTransactionAsync(async () => {
  await db.runAsync('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]);
  await db.runAsync('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]);
  // Se qualcosa fallisce, viene fatto rollback automatico
});

// Transazione esclusiva (blocca altri accessi in scrittura)
await db.withExclusiveTransactionAsync(async () => {
  // Operazioni critiche qui
});

Provider React e Context

import { SQLiteProvider, useSQLiteContext } from 'expo-sqlite';

async function migrateDb(db: SQLite.SQLiteDatabase) {
  await db.execAsync(`
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      email TEXT UNIQUE NOT NULL,
      name TEXT NOT NULL
    );
  `);
}

// Nel componente root
function App() {
  return (
    <SQLiteProvider databaseName="app.db" onInit={migrateDb}>
      <UserList />
    </SQLiteProvider>
  );
}

// In qualsiasi componente figlio
function UserList() {
  const db = useSQLiteContext();
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    db.getAllAsync<User>('SELECT * FROM users').then(setUsers);
  }, []);

  return /* render della lista */;
}

KV-Store: il Sostituto di AsyncStorage

Se vuoi migrare da AsyncStorage con il minimo sforzo, expo-sqlite include un KV-Store con API compatibile e in più offre metodi sincroni. Praticamente è un drop-in replacement:

import Storage from 'expo-sqlite/kv-store';

// API asincrona (stessa di AsyncStorage)
await Storage.setItem('user_token', 'abc123');
const token = await Storage.getItem('user_token');

// API sincrona (esclusiva di expo-sqlite)
Storage.setItemSync('theme', 'dark');
const theme = Storage.getItemSync('theme');

// Operazioni batch
await Storage.multiSet([['key1', 'val1'], ['key2', 'val2']]);
const values = await Storage.multiGet(['key1', 'key2']);

op-sqlite: Prestazioni SQLite al Massimo

op-sqlite v15.2.10 (aprile 2026) è l'alternativa più performante per chi ha bisogno di spremere ogni millisecondo da SQLite. Costruita interamente in C con binding JSI diretti, è pensata per app con dataset grandi e query complesse. Non è per tutti, ma quando serve, serve davvero.

Quando Scegliere op-sqlite rispetto a Expo SQLite

Caratteristicaop-sqliteExpo SQLite
Velocità query grandi (300k+ righe)Significativamente più veloceBuona
Query piccole (<100 righe)ComparabileComparabile
Plugin disponibiliSQLCipher, libSQL, FTS5, Rtree, cr-sqlite, sqlite-vecSQLCipher, libSQL, FTS, sqlite-vec
DevTools integratiNoSì (inspector browser)
ORM supportatiDrizzle, KyselyDrizzle, Kysely
Expo managed workflowConfig plugin necessarioIntegrato nativamente
Ricerca vettoriale (AI)sqlite-vec integratosqlite-vec (SDK 53+)

In sostanza: se usi Expo e le tue query operano su dataset di dimensioni normali, Expo SQLite è la scelta più pratica. Se invece lavori con centinaia di migliaia di righe, fai ricerche full-text intensive o hai bisogno di sqlite-vec per AI on-device, op-sqlite ti dà quel margine di prestazioni in più che può fare la differenza.

import { open } from '@op-engineering/op-sqlite';

// Apri il database
const db = open({ name: 'app.db' });

// Query con risultati tipizzati
const { rows } = db.execute(
  'SELECT * FROM products WHERE price > ?',
  [50]
);

// Transazione
db.transaction((tx) => {
  tx.execute('INSERT INTO products (name, price) VALUES (?, ?)', ['AirPods', 249]);
  tx.execute('INSERT INTO products (name, price) VALUES (?, ?)', ['iPad', 799]);
});

// Reactive queries — il callback scatta quando i dati cambiano
const unsubscribe = db.reactiveExecute({
  query: 'SELECT * FROM products WHERE category = ?',
  arguments: ['electronics'],
  fireOn: [{ table: 'products' }],
  callback: (response) => {
    console.log('Dati aggiornati:', response.rows);
  },
});

WatermelonDB: Database per Dataset Grandi con Sync

Quando la tua app gestisce decine di migliaia di record e ha bisogno di sincronizzazione con un backend, WatermelonDB è progettato esattamente per questo scenario. A differenza delle altre soluzioni, WatermelonDB non è solo uno storage: è un vero ORM reattivo con lazy loading e un protocollo di sync integrato.

Detto questo, è anche la soluzione con la curva di apprendimento più ripida. Ne vale la pena? Dipende dal tuo caso d'uso.

Filosofia: Caricamento Lazy, UI Istantanea

Il problema con gli store basati su Redux o sullo stato globale è che caricano tutto in memoria all'avvio. Con dataset grandi (50k+ record) questo significa 2-5 secondi di attesa su dispositivi lenti — un'eternità per l'utente. WatermelonDB ribalta l'approccio:

  • Niente viene caricato finché non serve: le query girano direttamente su SQLite in un thread nativo separato
  • Completamente reattivo: l'UI si aggiorna automaticamente quando i dati cambiano
  • Avvio istantaneo: nessun caricamento iniziale, nessun ritardo percepibile

Definizione dei Modelli

import { Model } from '@nozbe/watermelondb';
import { field, text, date, children, relation } from '@nozbe/watermelondb/decorators';

class Product extends Model {
  static table = 'products';
  static associations = {
    reviews: { type: 'has_many' as const, foreignKey: 'product_id' },
    category: { type: 'belongs_to' as const, key: 'category_id' },
  };

  @text('name') name!: string;
  @field('price') price!: number;
  @text('description') description!: string;
  @date('created_at') createdAt!: Date;
  @relation('categories', 'category_id') category: any;
  @children('reviews') reviews: any;
}

class Review extends Model {
  static table = 'reviews';

  @text('body') body!: string;
  @field('rating') rating!: number;
  @relation('products', 'product_id') product: any;
}

Query e Osservazione Reattiva

import { Q } from '@nozbe/watermelondb';

// Query con filtri
const expensiveProducts = await database
  .get<Product>('products')
  .query(
    Q.where('price', Q.gt(100)),
    Q.sortBy('created_at', Q.desc),
    Q.take(20)
  )
  .fetch();

// Osservazione reattiva in un componente React
import { withObservables } from '@nozbe/watermelondb/react';

const ProductCard = ({ product }: { product: Product }) => (
  <View>
    <Text>{product.name}</Text>
    <Text>€{product.price}</Text>
  </View>
);

const enhance = withObservables(['product'], ({ product }: { product: Product }) => ({
  product: product.observe(),
}));

export default enhance(ProductCard);

Sincronizzazione con il Backend

Questa è la funzionalità killer di WatermelonDB, e il motivo principale per cui lo sceglieresti rispetto alle alternative. Il protocollo di sync è un processo in due fasi — pull (scarica le modifiche dal server) e push (invia le modifiche locali) — con gestione dei conflitti integrata:

import { synchronize } from '@nozbe/watermelondb/sync';

async function syncWithServer() {
  await synchronize({
    database,

    pullChanges: async ({ lastPulledAt, schemaVersion, migration }) => {
      const response = await fetch(
        `https://api.example.com/sync?last_pulled_at=${lastPulledAt}` +
        `&schema_version=${schemaVersion}`
      );
      if (!response.ok) throw new Error('Sync pull fallito');

      const { changes, timestamp } = await response.json();
      return { changes, timestamp };
    },

    pushChanges: async ({ changes, lastPulledAt }) => {
      const response = await fetch(
        `https://api.example.com/sync?last_pulled_at=${lastPulledAt}`,
        { method: 'POST', body: JSON.stringify(changes) }
      );
      if (!response.ok) throw new Error('Sync push fallito');
    },

    migrationsEnabledAtVersion: 1,
  });
}

Per dataset molto grandi (50k+ record), WatermelonDB offre anche modalità ottimizzate:

  • Replacement Sync: il server invia il dataset completo e l'app sostituisce il DB locale preservando le modifiche non sincronizzate
  • Turbo Login: per il primo sync su un database vuoto, il JSON grezzo viene passato direttamente al layer nativo saltando il parsing JS — molto più veloce

TanStack Query + MMKV: Data Fetching Offline-First

Le soluzioni viste finora gestiscono lo storage locale. Ma che succede quando i dati arrivano da un'API e vuoi che siano disponibili anche offline? Il pattern TanStack Query + MMKV persistence è la risposta moderna a questo problema: combina il data fetching intelligente di TanStack Query con la velocità di MMKV per la cache persistente.

È il pattern che uso più spesso nei progetti recenti, e ti spiego perché.

Come Funziona

L'idea è semplice: TanStack Query gestisce il ciclo di vita dei dati (fetch, cache, invalidazione, retry) mentre MMKV salva la cache su disco. Quando l'app si riavvia o perde connessione, i dati sono già disponibili istantaneamente dalla cache locale. Nessuna schermata di caricamento, nessun spinner. L'utente non si accorge nemmeno di essere offline.

Setup Completo Step-by-Step

1. Installa le dipendenze:

npx expo install @tanstack/react-query \
  @tanstack/react-query-persist-client \
  @tanstack/query-async-storage-persister \
  react-native-mmkv react-native-nitro-modules \
  @react-native-community/netinfo

2. Crea il persister MMKV:

// lib/query-persister.ts
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { createMMKV } from 'react-native-mmkv';

const storage = createMMKV({ id: 'tanstack.cache' });

const clientStorage = {
  setItem: (key: string, value: string) => { storage.set(key, value); },
  getItem: (key: string) => storage.getString(key) ?? null,
  removeItem: (key: string) => { storage.remove(key); },
};

export const queryPersister = createAsyncStoragePersister({
  storage: clientStorage,
});

3. Configura il QueryClient per offline-first:

// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: Infinity,            // Non eliminare mai la cache
      staleTime: 1000 * 60 * 5,   // Dati "freschi" per 5 minuti
      networkMode: 'offlineFirst', // Usa la cache prima, poi fetch
      retry: 2,
    },
    mutations: {
      networkMode: 'offlineFirst',
    },
  },
});

4. Configura il monitoraggio della connessione:

// lib/online-manager.ts
import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';

onlineManager.setEventListener((setOnline) =>
  NetInfo.addEventListener((state) => {
    setOnline(Boolean(state.isConnected));
  })
);

5. Integra tutto nell'App:

// App.tsx
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { queryClient } from './lib/query-client';
import { queryPersister } from './lib/query-persister';
import './lib/online-manager';

export default function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister: queryPersister,
        maxAge: Infinity, // Mantieni la cache persistente per sempre
      }}
    >
      <NavigationContainer>
        {/* Le tue schermate */}
      </NavigationContainer>
    </PersistQueryClientProvider>
  );
}

6. Usa i dati nei componenti:

import { useQuery } from '@tanstack/react-query';

interface Product {
  id: number;
  name: string;
  price: number;
}

function ProductList() {
  const { data, isLoading, isError, dataUpdatedAt } = useQuery<Product[]>({
    queryKey: ['products'],
    queryFn: async () => {
      const res = await fetch('https://api.example.com/products');
      if (!res.ok) throw new Error('Errore nel caricamento');
      return res.json();
    },
  });

  if (isLoading && !data) return <Text>Caricamento...</Text>;

  return (
    <View>
      {data?.map((p) => (
        <Text key={p.id}>{p.name} — €{p.price}</Text>
      ))}
      <Text style={{ color: '#999', fontSize: 12 }}>
        Ultimo aggiornamento: {new Date(dataUpdatedAt).toLocaleString('it-IT')}
      </Text>
    </View>
  );
}

Un avvertimento importante: il persister predefinito serializza l'intero stato del QueryClient a ogni modifica. Con molte query attive, questo può significare diversi MB di JSON. Per app complesse, considera di implementare un persister personalizzato che salvi le query individualmente — ti risparmierà problemi di performance su dispositivi meno potenti.

Storage Sicuro: Proteggere i Dati Sensibili

Token di autenticazione, chiavi API, dati biometrici — certi dati non possono essere salvati in chiaro. Punto. Ecco le opzioni per lo storage sicuro nel 2026:

expo-secure-store

La scelta migliore per progetti Expo. Usa il Keychain su iOS e EncryptedSharedPreferences su Android:

import * as SecureStore from 'expo-secure-store';

// Salva un token (crittografato dal sistema operativo)
await SecureStore.setItemAsync('auth_token', 'eyJhbGciOi...');

// Leggi il token
const token = await SecureStore.getItemAsync('auth_token');

// Rimuovi
await SecureStore.deleteItemAsync('auth_token');

MMKV con Crittografia AES-256

Per dati che devono essere sia veloci che crittografati, MMKV con AES-256 è un'ottima alternativa. Il trucco è salvare la chiave nel Keychain di sistema:

import * as SecureStore from 'expo-secure-store';
import { createMMKV } from 'react-native-mmkv';

// Recupera la chiave dal Keychain (o generala al primo avvio)
let encKey = await SecureStore.getItemAsync('mmkv_encryption_key');
if (!encKey) {
  encKey = generateRandomKey(); // La tua funzione di generazione
  await SecureStore.setItemAsync('mmkv_encryption_key', encKey);
}

// Crea istanza MMKV crittografata
const secureStorage = createMMKV({
  id: 'secure.data',
  encryptionKey: encKey,
  encryptionType: 'AES-256',
});

Raccomandazioni per Tipo di Dato

Tipo di datoSoluzione consigliata
Token JWT, refresh tokenexpo-secure-store
Chiavi API, segretiexpo-secure-store
Dati utente sensibiliMMKV + AES-256 (chiave dal Keychain)
Preferenze, impostazioniMMKV (senza crittografia)
Dati relazionaliExpo SQLite + SQLCipher
Cache APITanStack Query + MMKV

Mappa Decisionale: Quale Storage Scegliere

Ok, abbiamo visto un sacco di opzioni. Ecco la guida pratica per non perdersi:

Usa AsyncStorage quando:

  • Stai prototipando o è un progetto molto semplice
  • Devi salvare solo poche preferenze (tema, lingua, flag)
  • Lavori con Expo Go e non puoi fare prebuild

Usa MMKV quando:

  • Hai bisogno di key-value storage veloce (sostituisce AsyncStorage)
  • Vuoi accesso sincrono ai dati
  • Vuoi persistere lo stato di Zustand o Jotai
  • Hai bisogno di crittografia AES dei dati
  • Vuoi usare React hooks reattivi per lo storage

Usa Expo SQLite quando:

  • I tuoi dati hanno relazioni (tabelle, join, indici)
  • Hai bisogno di query complesse con filtri e ordinamento
  • Usi Expo e vuoi l'integrazione più semplice possibile
  • Hai bisogno di ricerca full-text (FTS)
  • Vuoi il KV-Store come drop-in replacement di AsyncStorage

Usa op-sqlite quando:

  • Lavori con dataset molto grandi (300k+ righe)
  • Hai bisogno di prestazioni SQLite massime
  • Usi sqlite-vec per ricerche vettoriali e AI on-device
  • Il tuo progetto non è gestito con Expo managed workflow

Usa WatermelonDB quando:

  • La tua app ha 10k-100k+ record con sincronizzazione server
  • Hai bisogno di un ORM reattivo con lazy loading
  • Vuoi un protocollo di sync pull/push integrato
  • L'avvio istantaneo è critico anche con grandi dataset

Usa TanStack Query + MMKV quando:

  • I dati arrivano da API REST/GraphQL e vuoi cache offline persistente
  • Vuoi che l'app funzioni offline con i dati già scaricati
  • Hai bisogno di invalidazione intelligente e refetch automatico

Combinare le Soluzioni: l'Architettura Completa

Nella pratica, la maggior parte delle app production-ready usa una combinazione di queste soluzioni. Non devi sceglierne una sola — anzi, non dovresti. Ecco un esempio di architettura completa per un'app e-commerce:

// Architettura storage di un'app e-commerce

// 1. MMKV — preferenze utente e stato UI
const preferences = createMMKV({ id: 'prefs' });
//    tema, lingua, filtri salvati, cronologia ricerche

// 2. expo-secure-store — dati sensibili
//    token JWT, refresh token, chiavi API

// 3. Expo SQLite — dati relazionali offline
//    catalogo prodotti, ordini, carrello, indirizzi

// 4. TanStack Query + MMKV — cache API
//    feed prodotti, recensioni, raccomandazioni
//    disponibili offline dalla cache persistente

// 5. WatermelonDB — (se necessario)
//    solo per app con sync bidirezionale complesso
//    e dataset molto grandi

FAQ

Qual è il miglior database locale per React Native nel 2026?

Non esiste una risposta unica — dipende dal tuo caso d'uso specifico. Per key-value storage, MMKV è la scelta migliore grazie alle prestazioni 20-30x superiori rispetto ad AsyncStorage. Per dati relazionali, Expo SQLite è l'opzione più pratica nell'ecosistema Expo, mentre op-sqlite offre prestazioni superiori per dataset molto grandi. Per app con sincronizzazione server e decine di migliaia di record, WatermelonDB rimane la soluzione più completa.

Come salvare dati sensibili come token di autenticazione in React Native?

Usa expo-secure-store per progetti Expo o react-native-keychain per progetti bare. Entrambi sfruttano il Keychain su iOS e l'EncryptedSharedPreferences su Android. Non salvare mai token in AsyncStorage o MMKV senza crittografia — i dati sarebbero accessibili in chiaro sul filesystem del dispositivo.

AsyncStorage è ancora valido nel 2026?

Per prototipi e dati molto semplici (poche preferenze), AsyncStorage funziona ancora. Ma per qualsiasi app in produzione, è fortemente consigliato migrare a MMKV per le prestazioni o al KV-Store di Expo SQLite come drop-in replacement con supporto per API sincrone. Il costo della migrazione è minimo e i benefici sono immediati.

Come funziona un'app React Native offline-first?

L'approccio più comune nel 2026 usa TanStack Query con MMKV persistence: i dati vengono scaricati dalle API, memorizzati nella cache locale e serviti immediatamente all'avvio, anche senza connessione. Per dati relazionali complessi con sync bidirezionale, WatermelonDB offre un protocollo pull/push integrato con gestione dei conflitti.

MMKV funziona con Expo Go?

No, purtroppo. MMKV v4 richiede la Nuova Architettura e moduli nativi compilati, quindi è necessario usare Expo Dev Client (tramite npx expo prebuild) oppure le development build di EAS. In Expo Go puoi usare AsyncStorage o il KV-Store di Expo SQLite come alternative temporanee.

Sull'Autore Devon Nakashima

Devon is a principal engineer who has been writing React Native since the 0.40 days, with fifteen years total in mobile and web. He led the rewrite of the Wealthsimple trading app from native iOS/Android to a shared RN codebase, then spent two years at Discord on the mobile experience team working on the New Architecture migration and the Hermes upgrade that shipped to 200M+ installs. These days he's an independent consultant and contracts mostly with healthtech and developer-tools companies. He's an occasional contributor to React Navigation and was a maintainer on react-native-mmkv for about a year. His writing here is opinionated and tends toward architecture-level decisions: when to drop down to native modules, how to structure feature flags across iOS and Android, and why he thinks Expo's prebuild model finally won the bare-vs-managed debate around 2024.