SQLite in React Native: Lokale Datenhaltung praktisch umgesetzt
AI generated
RN
native
React Native · SQLite · Lokale Datenhaltung · Mobile Apps
SQLite: Lokale Datenhaltung praktisch umgesetzt
von expo-sqlite bis zur getesteten Migration

Wer in React-Native-Apps größere Datenmengen offline verfügbar machen will, kommt an einer echten relationalen Datenbank kaum vorbei. SQLite liefert mit expo-sqlite eine schnelle, native lokale Datenhaltung ohne ORM-Overhead: handgeschriebenes SQL, versionierte Migrationen und parametrisierte Queries, die volle Kontrolle über Schema und Performance geben.

17 Min. Lesezeit expo-sqlite · PRAGMA user_version · withTransactionAsync React Native · SQLite 3 · Expo SDK 51+

1. Warum SQLite in React-Native-Apps und wo AsyncStorage an seine Grenzen stößt

AsyncStorage speichert ausschließlich Key-Value-Paare als Strings. Für ein paar Einstellungen oder einen Auth-Token reicht das aus, doch sobald eine App hunderte oder tausende zusammenhängende Datensätze offline verfügbar halten muss, wie Bestellungen, Produktkataloge oder Chatverläufe, wird jede einzelne Abfrage zum Problem. Es gibt keine Filterung, keine Sortierung und keine Joins auf Datenbankebene: jeder Lesezugriff bedeutet, den kompletten JSON-String zu laden, zu parsen und danach in JavaScript zu filtern. Bei wachsender Datenmenge steigt die Zeit für Deserialisierung linear mit, während der Hauptthread blockiert bleibt.

SQLite löst dieses Problem, indem es eine vollwertige relationale Datenbank direkt auf dem Gerät bereitstellt. Über expo-sqlite oder react-native-sqlite-storage spricht die App mit einer nativen SQLite-Engine, die über JSI angebunden ist, also ohne die klassische asynchrone Bridge-Serialisierung. Für eine lokale Datenhaltung mit echten Abfragen, Indizes und Transaktionsgarantien ist das der einzig praktikable Weg: SQL-Queries filtern und sortieren direkt in der Datenbank-Engine, nicht im JavaScript-Thread.

Dieser Artikel behandelt bewusst den direkten SQL-Ansatz ohne ORM-Abstraktionsschicht. Im Gegensatz zu WatermelonDB, das Reaktivität und eine eingebaute Sync-Engine über ein eigenes Modellsystem abbildet, wird hier handgeschriebenes SQL mit parametrisierten Queries und einem eigenen Migrationssystem eingesetzt. Das bedeutet mehr Kontrolle über jede einzelne Abfrage, aber auch mehr Eigenverantwortung bei Schema-Änderungen und Query-Optimierung.

2. Setup mit expo-sqlite

Die Installation von expo-sqlite erfolgt über den Expo-CLI-Befehl, der automatisch die passende native Version für das aktuelle Expo SDK auswählt. In einem bare React-Native-Projekt ohne Expo-Managed-Workflow kommt stattdessen react-native-sqlite-storage zum Einsatz, das manuelles natives Linking über CocoaPods und Gradle benötigt. Seit Expo SDK 51 bietet expo-sqlite eine vollständig Promise-basierte API mit openDatabaseAsync, execAsync, runAsync, getAllAsync und getFirstAsync, die den älteren callback-basierten Ansatz vollständig ablöst.

Die eigentliche SQLite-Datenbankdatei liegt im sandboxed Dokumentenverzeichnis der App und ist damit von anderen Apps isoliert, ein wichtiger Aspekt für lokale Datenhaltung ohne Cloud-Abhängigkeit. Beim ersten Öffnen mit openDatabaseAsync("app.db") wird die Datei automatisch angelegt, falls sie noch nicht existiert. In der app.json kann das Expo-Config-Plugin zusätzliche native Optionen setzen, etwa ob SQLCipher für verschlüsselte lokale Datenhaltung aktiviert werden soll.


# Install expo-sqlite for local SQLite-based data storage
npx expo install expo-sqlite

# For bare React Native projects (no Expo managed workflow)
npm install react-native-sqlite-storage
cd ios && pod install

{
  "expo": {
    "name": "InventoryApp",
    "plugins": [
      [
        "expo-sqlite",
        {
          "enableFTS": true,
          "useSQLCipher": true,
          "android": {
            "enableFTS": true,
            "useSQLCipher": true
          }
        }
      ]
    ]
  }
}

3. Schema-Design und Migrationen

Ein relationales Schema für lokale Datenhaltung beginnt mit klaren Tabellen, Primärschlüsseln und Fremdschlüsselbeziehungen, genau wie in einer Server-Datenbank. Der Unterschied liegt in der Versionierung: SQLite bietet mit PRAGMA user_version einen eingebauten Integer-Zähler direkt in der Datenbankdatei, der sich hervorragend als Schema-Version eignet. Beim App-Start liest man diesen Wert aus, vergleicht ihn mit der Zielversion im Code und führt jede fehlende Migration nacheinander aus.

Der Migrationsrunner ist damit kein externes Framework, sondern eine simple Schleife über nummerierte SQL-Skripte. Jede Migration bekommt eine eigene Funktion, die genau einen Versionssprung abbildet, etwa das Hinzufügen einer Spalte oder das Anlegen eines neuen Index. Nach erfolgreicher Ausführung wird PRAGMA user_version auf die neue Zahl gesetzt. Das garantiert, dass ein Nutzer, der von Version 3 direkt auf Version 7 aktualisiert, alle vier Zwischenschritte in der richtigen Reihenfolge durchläuft, ohne dass die App das jemals explizit prüfen muss.

Wichtig für stabile lokale Datenhaltung ist, dass Migrationen niemals destruktiv beginnen. ALTER TABLE ... ADD COLUMN ist in SQLite unkompliziert, aber das Entfernen einer Spalte erfordert bis vor kurzem den Umweg über eine neue Tabelle, Kopieren der Daten und Umbenennen. Wer diesen Ablauf in einer Transaktion kapselt, verhindert, dass eine abgebrochene Migration die Datenbank in einem inkonsistenten Zwischenzustand zurücklässt.


// db.js: database initialization and versioned migrations
import { openDatabaseAsync } from "expo-sqlite";

const TARGET_VERSION = 3;

// Each migration maps exactly one version step
const migrations = {
  1: async (db) => {
    await db.execAsync(`
      CREATE TABLE IF NOT EXISTS notes (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        body TEXT NOT NULL DEFAULT '',
        created_at INTEGER NOT NULL
      );
    `);
  },
  2: async (db) => {
    await db.execAsync(`ALTER TABLE notes ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0;`);
  },
  3: async (db) => {
    await db.execAsync(`CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at);`);
  },
};

export async function openDatabase() {
  const db = await openDatabaseAsync("app.db");
  const result = await db.getFirstAsync("PRAGMA user_version;");
  let currentVersion = result.user_version;

  while (currentVersion < TARGET_VERSION) {
    currentVersion += 1;
    await migrations[currentVersion](db);
    await db.execAsync(`PRAGMA user_version = ${currentVersion};`);
  }

  return db;
}

4. CRUD-Operationen praktisch umgesetzt

CRUD auf einer SQLite-basierten lokalen Datenhaltung folgt denselben Grundregeln wie auf jeder anderen relationalen Datenbank: Werte gehören niemals per String-Konkatenation in eine Query, sondern immer als gebundene Parameter. runAsync übernimmt INSERT, UPDATE und DELETE und liefert unter anderem lastInsertRowId und changes zurück, sodass man ohne zusätzliche Abfrage weiß, wie viele Zeilen betroffen waren. Für SELECT stehen getAllAsync für Listen und getFirstAsync für einen einzelnen Datensatz oder null bereit.

Parametrisierte Queries mit Platzhaltern wie ? schützen nicht nur vor SQL-Injection, sie sorgen auch dafür, dass SQLite denselben Prepared Statement wiederverwenden kann, was bei wiederholten Abfragen mit unterschiedlichen Werten spürbar schneller ist als eine neu geparste Query pro Aufruf. In einer React-Native-Komponente kapselt man diese Zugriffe typischerweise in einem eigenen Hook, der den Datenbank-Handle aus einem Context bezieht und Lade- sowie Fehlerzustände verwaltet.

Für lokale Datenhaltung mit häufigen Änderungen aus mehreren Bildschirmen empfiehlt sich außerdem ein einfaches Event-System oder ein globaler Store, der nach jedem Schreibzugriff die betroffenen Listen neu lädt. Anders als bei WatermelonDB gibt es hier keine eingebauten Observables, die Änderungen automatisch propagieren, das muss man mit direktem SQLite bewusst selbst verdrahten.


// useNotes.js: custom hook for CRUD access to local SQLite data
import { useCallback, useEffect, useState } from "react";
import { useDatabase } from "./DatabaseProvider";

export function useNotes() {
  const db = useDatabase();
  const [notes, setNotes] = useState([]);
  const [loading, setLoading] = useState(true);

  const loadNotes = useCallback(async () => {
    setLoading(true);
    const rows = await db.getAllAsync(
      "SELECT * FROM notes WHERE is_archived = ? ORDER BY created_at DESC;",
      [0]
    );
    setNotes(rows);
    setLoading(false);
  }, [db]);

  const createNote = useCallback(
    async (title, body) => {
      const result = await db.runAsync(
        "INSERT INTO notes (title, body, created_at) VALUES (?, ?, ?);",
        [title, body, Date.now()]
      );
      await loadNotes();
      return result.lastInsertRowId;
    },
    [db, loadNotes]
  );

  const archiveNote = useCallback(
    async (id) => {
      await db.runAsync("UPDATE notes SET is_archived = 1 WHERE id = ?;", [id]);
      await loadNotes();
    },
    [db, loadNotes]
  );

  const deleteNote = useCallback(
    async (id) => {
      await db.runAsync("DELETE FROM notes WHERE id = ?;", [id]);
      await loadNotes();
    },
    [db, loadNotes]
  );

  useEffect(() => {
    loadNotes();
  }, [loadNotes]);

  return { notes, loading, createNote, archiveNote, deleteNote, reload: loadNotes };
}

5. Transaktionen und Batch-Operationen

Wenn mehrere zusammenhängende Schreibzugriffe entweder komplett oder gar nicht ausgeführt werden dürfen, ist eine explizite Transaktion Pflicht. expo-sqlite bietet dafür withTransactionAsync, das einen Callback entgegennimmt und automatisch ein COMMIT ausführt, wenn der Callback erfolgreich durchläuft, beziehungsweise ein ROLLBACK, sobald eine Exception auftritt. Ein typisches Beispiel für lokale Datenhaltung ist das Anlegen einer Bestellung zusammen mit ihren Positionen: entweder landen alle Zeilen in der Datenbank, oder keine einzige.

Für Massenoperationen wie den Import eines vollständigen Produktkatalogs ist eine Transaktion nicht nur wegen der Konsistenz wichtig, sondern auch wegen der Performance. Jedes einzelne INSERT außerhalb einer Transaktion erzwingt standardmäßig einen eigenen Disk-Sync, was bei tausenden Zeilen zu spürbaren Verzögerungen führt. Wird derselbe Import in eine einzige Transaktion gepackt, reduziert sich die Anzahl der Fsync-Aufrufe auf einen einzigen am Ende, was den Import oft um eine Größenordnung beschleunigt.

Verschachtelte Transaktionen unterstützt SQLite über Savepoints, was in der Praxis selten gebraucht wird, aber nützlich ist, wenn ein Teilschritt innerhalb einer größeren Transaktion isoliert zurückgerollt werden soll, ohne die äußere Transaktion abzubrechen. Für die meisten Anwendungsfälle in einer React-Native-App reicht jedoch eine flache Transaktion pro logischer Operation vollkommen aus.

6. Indizes und Query-Performance

Ohne passenden Index durchsucht SQLite bei jeder gefilterten oder sortierten Abfrage die komplette Tabelle, ein sogenannter Full Table Scan. Bei kleinen Tabellen mit ein paar hundert Zeilen fällt das kaum auf, bei Tabellen mit zehntausenden Einträgen, etwa einem lokalen Cache von Produktdaten, wird jede Abfrage spürbar langsamer, je mehr Zeilen die App offline hält. CREATE INDEX idx_name ON table(column) legt für genau die Spalten einen B-Tree-Index an, nach denen häufig gefiltert oder sortiert wird.

Um zu verstehen, ob ein Index tatsächlich genutzt wird, hilft EXPLAIN QUERY PLAN vor der eigentlichen Query. Die Ausgabe zeigt, ob SQLite einen Index-Scan oder einen vollständigen Tabellenscan durchführt, und bei Joins, in welcher Reihenfolge die Tabellen verknüpft werden. Ein häufiger Fehler in der lokalen Datenhaltung ist ein Index auf einer Spalte, die in der WHERE-Klausel durch eine Funktion verändert wird, etwa WHERE lower(name) = ?, denn ein normaler Index greift hier nicht. Abhilfe schafft ein expliziter Ausdrucksindex auf genau diesem Ausdruck.

Zusammengesetzte Indizes über mehrere Spalten lohnen sich, wenn eine Abfrage regelmäßig nach denselben zwei oder drei Spalten gemeinsam filtert oder sortiert, etwa CREATE INDEX idx_notes_status_date ON notes(is_archived, created_at). Zu viele Indizes verlangsamen dagegen jeden Schreibzugriff, weil jeder Index bei einem INSERT oder UPDATE mitgepflegt werden muss. Für lokale Datenhaltung mit häufigen Schreibzugriffen ist ein bewusster Kompromiss zwischen Lese- und Schreibperformance nötig.

7. Verschlüsselung und Sicherheit lokaler Daten

Eine unverschlüsselte SQLite-Datei liegt als reguläre Datei im Dateisystem des Geräts. Auf einem gerooteten Android-Gerät oder bei physischem Zugriff auf ein iPhone im Backup lässt sich diese Datei mit gängigen Tools direkt auslesen. Für sensible lokale Datenhaltung, etwa Gesundheitsdaten, Zahlungsinformationen oder private Nachrichten, reicht die Sandbox-Isolation des Betriebssystems allein nicht aus.

SQLCipher verschlüsselt die komplette Datenbankdatei transparent mit AES-256 und lässt sich über die useSQLCipher-Option des Expo-Config-Plugins aktivieren. Beim Öffnen der Datenbank wird zusätzlich ein Passphrase-Parameter übergeben, der idealerweise nicht hartkodiert, sondern aus dem sicheren Schlüsselspeicher des Geräts stammt, unter iOS aus der Keychain, unter Android aus dem Android Keystore, beides erreichbar über expo-secure-store.

Ein wichtiger Punkt bei verschlüsselter lokaler Datenhaltung: Der Verschlüsselungs-Overhead betrifft jede einzelne Seite, die SQLite liest oder schreibt, nicht nur die initiale Verbindung. Bei sehr großen Datenbanken mit intensiven Schreiblasten lohnt sich ein Benchmark vor und nach Aktivierung von SQLCipher, um zu verifizieren, dass die Performance in der Zielgruppe der unterstützten Geräte weiterhin akzeptabel bleibt.

8. Testing von SQLite-Code

Code, der direkt gegen SQLite schreibt, lässt sich am zuverlässigsten mit einer echten, aber temporären Datenbank testen, statt die Datenbankschicht komplett zu mocken. expo-sqlite unterstützt den speziellen Datenbanknamen :memory:, der eine vollständige SQLite-Instanz ausschließlich im Arbeitsspeicher anlegt. Jeder Test öffnet eine frische In-Memory-Datenbank, führt dieselben Migrationen wie die Produktionsapp aus und verifiziert danach das tatsächliche Verhalten von Queries, Constraints und Transaktionen.

Dieser Ansatz deckt Fehler auf, die ein reines Mock niemals finden würde, etwa eine vergessene NOT NULL-Constraint, einen fehlerhaften Fremdschlüssel oder eine Migration, die bei einem bestimmten Zwischenzustand fehlschlägt. Für Jest-Tests bedeutet das: In einem beforeEach-Block wird die In-Memory-Datenbank neu geöffnet und migriert, in afterEach wird sie geschlossen, sodass Tests vollständig isoliert voneinander laufen und sich nicht gegenseitig beeinflussen.

Für Komponenten, die den Datenbank-Handle über Context beziehen, lässt sich in Tests derselbe Provider mit einer In-Memory-Datenbank statt der echten Datei einsetzen. Damit testet man mit React Testing Library die komplette Kette von UI-Interaktion bis zur tatsächlichen SQL-Query, ohne eine einzige Zeile der Produktionslogik zu mocken.


// notes.test.js: testing SQLite-backed logic with an in-memory database
import { openDatabaseAsync } from "expo-sqlite";
import { runMigrations } from "../db/migrations";
import { createNote, archiveNote } from "../db/notesRepository";

describe("notesRepository", () => {
  let db;

  beforeEach(async () => {
    // A fresh in-memory database for every test, fully isolated
    db = await openDatabaseAsync(":memory:");
    await runMigrations(db);
  });

  afterEach(async () => {
    await db.closeAsync();
  });

  it("creates a note and returns its generated id", async () => {
    const id = await createNote(db, "Shopping list", "Milk, eggs, bread");
    const row = await db.getFirstAsync("SELECT * FROM notes WHERE id = ?;", [id]);

    expect(row.title).toBe("Shopping list");
    expect(row.is_archived).toBe(0);
  });

  it("archives a note without deleting it", async () => {
    const id = await createNote(db, "Old note", "");
    await archiveNote(db, id);
    const row = await db.getFirstAsync("SELECT * FROM notes WHERE id = ?;", [id]);

    expect(row.is_archived).toBe(1);
  });
});

9. SQLite im Vergleich zu WatermelonDB und MMKV für unterschiedliche Anwendungsfälle

Direktes SQLite, WatermelonDB und MMKV lösen alle das Problem der lokalen Datenhaltung, aber mit sehr unterschiedlichen Kompromissen zwischen Kontrolle, Reaktivität und Setup-Aufwand. Die Wahl hängt weniger von persönlichen Vorlieben als vom konkreten Datenmodell und der geforderten Reaktivität der App ab.

Dimension SQLite (direkt) WatermelonDB MMKV
Query-Fähigkeit Voller SQL-Funktionsumfang, Joins, Aggregationen Eingeschränktes Query-Builder-API über SQLite-Unterbau Nur Key-Value-Zugriff, keine Queries
Reaktivität Manuell, eigenes Event-System nötig Eingebaute Observables je Datensatz Manuell, keine eingebaute Reaktivität
Sync-Unterstützung Selbst implementiert Eingebaute Sync-Engine (synchronize()) Keine, reiner lokaler Speicher
Bester Anwendungsfall Relationale Daten mit komplexen Abfragen, eigenes Migrationsschema Offline-First-Apps mit Server-Sync und vielen UI-Bindings Einstellungen, Flags, kleine Caches
Setup-Aufwand Mittel, eigenes Schema und Migrationen nötig Hoch, Modelle, Schema und Sync-Protokoll definieren Sehr gering, sofort einsatzbereit

Für eine App, die primär strukturierte, relationale Daten offline verfügbar halten muss und keine automatische Server-Synchronisation benötigt, ist direktes SQLite meist die pragmatischste Wahl: weniger Abstraktionsebenen, volle Kontrolle über Schema und Query-Plan. Sobald Reaktivität über viele Bildschirme hinweg und eine eingebaute Sync-Engine gebraucht werden, gleicht WatermelonDB diesen zusätzlichen Aufwand mit weniger Boilerplate-Code aus. MMKV bleibt die richtige Wahl für einfache Key-Value-Daten ohne jeden relationalen Anspruch.

Mironsoft

React-Native-Entwicklung und lokale Datenhaltung für mobile Apps

SQLite-basierte lokale Datenhaltung für eure App?

Wir konzipieren Schema, Migrationen und Zugriffsschicht für eure React-Native-App: von der ersten expo-sqlite-Integration über Transaktionen und Indizes bis zur verschlüsselten und vollständig getesteten lokalen Datenhaltung.

Schema & Migrationen

Versioniertes SQLite-Schema mit PRAGMA user_version und sicheren Migrationspfaden

Performance-Tuning

Indizes, Query-Analyse mit EXPLAIN QUERY PLAN und Transaktions-Batching

Sicherheit & Tests

SQLCipher-Verschlüsselung und In-Memory-Testsuiten für zuverlässigen Code

10. Zusammenfassung

SQLite bleibt für lokale Datenhaltung in React-Native-Apps die robusteste Wahl, sobald AsyncStorage an seine Grenzen stößt: echte SQL-Queries statt String-Parsing, ACID-Transaktionen statt Ad-hoc-Konsistenz und ein eingebauter Versionszähler über PRAGMA user_version für saubere Migrationen. Mit expo-sqlite steht dafür eine moderne, Promise-basierte API bereit, die parametrisierte Queries, Batch-Transaktionen und In-Memory-Datenbanken für Tests direkt unterstützt, ganz ohne zusätzliche ORM-Abstraktion.

Wer sich für den direkten SQL-Weg entscheidet, übernimmt im Gegenzug die Verantwortung für Migrationen, Indizierung und Reaktivität selbst, statt sie an ein Framework wie WatermelonDB zu delegieren. Für relationale Daten mit komplexen Abfragen und eigenem Kontrollbedürfnis über Schema und Query-Plan ist das der richtige Kompromiss. Für einfache Key-Value-Daten bleibt MMKV die leichtgewichtigere Alternative, für Apps mit intensivem Server-Sync-Bedarf WatermelonDB die reaktivere Option.

SQLite: Lokale Datenhaltung praktisch umgesetzt, das Wichtigste auf einen Blick

Setup

expo-sqlite mit openDatabaseAsync, Promise-basierte API seit Expo SDK 51, native JSI-Bindings ohne Bridge-Serialisierung.

Migrationen

PRAGMA user_version als eingebauter Schema-Zähler, nummerierte Migrationsfunktionen für jeden Versionssprung.

Transaktionen & Performance

withTransactionAsync für atomare Batch-Operationen, Indizes und EXPLAIN QUERY PLAN gegen Full Table Scans.

Sicherheit & Tests

SQLCipher für verschlüsselte lokale Datenhaltung, :memory:-Datenbanken für isolierte, realistische Tests.

11. FAQ: SQLite und lokale Datenhaltung in React Native

1Was ist SQLite und wieso eignet es sich für lokale Datenhaltung?
Eine vollwertige relationale Datenbank als einzelne Datei auf dem Gerät, mit echten SQL-Queries, Transaktionen und Indizes statt reinem Key-Value-Storage.
2Wann expo-sqlite statt AsyncStorage nutzen?
Sobald hunderte oder tausende Datensätze gefiltert, sortiert oder verknüpft werden müssen. SQLite filtert direkt in der Datenbank-Engine, nicht im JavaScript-Thread.
3Wie funktionieren Migrationen mit PRAGMA user_version?
Ein Integer-Zähler in der Datenbankdatei. Beim Start werden alle fehlenden Migrationsfunktionen bis zur Zielversion nacheinander ausgeführt und der Zähler erhöht.
4Was sind parametrisierte Queries?
Werte werden über Platzhalter wie ? gebunden statt in den SQL-String eingefügt. Schützt vor SQL-Injection und erlaubt Wiederverwendung des Query-Plans.
5Wie funktionieren Transaktionen mit withTransactionAsync?
Ein Callback läuft innerhalb einer Transaktion, committet automatisch bei Erfolg und rollt bei einer Exception vollständig zurück.
6Wie verbessert man Query-Performance mit Indizes?
CREATE INDEX auf häufig gefilterten Spalten anlegen und mit EXPLAIN QUERY PLAN prüfen, ob der Index genutzt wird statt eines vollständigen Tabellenscans.
7Wie verschlüsselt man lokale SQLite-Daten?
SQLCipher verschlüsselt die Datenbankdatei transparent mit AES-256. Aktivierung über die useSQLCipher-Option, Passphrase idealerweise aus Keychain oder Android Keystore.
8Wie testet man Code, der SQLite verwendet?
Mit einer echten In-Memory-Datenbank über den Namen :memory:. Jeder Test bekommt eine frische, migrierte Instanz und testet reales Verhalten statt Mocks.
9Wann WatermelonDB statt direktem SQLite?
Wenn eingebaute Reaktivität über viele Bildschirme und eine fertige Sync-Engine benötigt werden. Direktes SQLite gibt dafür volle Kontrolle über Schema und Queries.
10Wann MMKV statt SQLite?
Für einfache Key-Value-Daten wie Einstellungen oder Feature-Flags ohne relationale Beziehungen. MMKV ist leichter, bietet aber keine Queries oder Transaktionen.