← React / TypeScript

React / TypeScript · 16 / 18

Eine kleine Offline-Hausakte mit React Native

Du verbindest native Erfassung, einen Service und ein auf Android und im Test austauschbares SQLite-Repository.

  • Groß
  • Anspruchsvoll
  • 120 min

Voraussetzungen: Komponenten und Repositories sinnvoll testen

Eine Offline-App benötigt keinen Server für ihre Kernaufgabe. Sie braucht aber eine verlässliche lokale Datenhaltung und klare Besitzgrenzen. Wir bauen eine kleine Hausliste mit normalisierten Namen. Ein SqlPort trennt Expo SQLite von Node SQLite. Derselbe Repository-Code wird deshalb im Gerät und im Test verwendet. Das Lernprodukt ist absichtlich kleiner als Hausakte. Fotos, PDFs, Backups und Erinnerungen sind zusätzliche Abläufe, die auf denselben Grenzen aufbauen.

01Die unabhängige Lernapp vorbereiten

Verwende die Expo-SDK-57-Lernapp aus Tutorial 12. Ergänze npx expo install expo-sqlite expo-crypto und npm install --save-dev vitest@4.1.10 @types/node. Speichere alle Beispieldateien. Liegen deine Routen unter src/app, passe nur den relativen Import von repository an.

02Fachregel und Port implementieren

Repository kennt nur drei SQL-Operationen. HouseService erhält außerdem einen ID-Erzeuger. Die Fachregel hängt damit weder von Expo noch von Node ab. Die UUID entsteht einmal pro bestätigter Erstellung.

src/repository.ts · typescriptDatei öffnen
export type House = { id: string; name: string };
export interface SqlPort {
  exec(sql: string): Promise<void>;
  run(sql: string, ...values: (string | number)[]): Promise<void>;
  all<T>(sql: string, ...values: (string | number)[]): Promise<T[]>;
}
export class Repository {
  constructor(private db: SqlPort) {}
  async migrate() {
    await this.db.exec('PRAGMA foreign_keys=ON');
    const rows = await this.db.all<{ user_version: number }>('PRAGMA user_version');
    if (rows[0].user_version > 1) throw new Error('Database is newer than this app');
    await this.db.exec(`BEGIN IMMEDIATE;
      CREATE TABLE IF NOT EXISTS houses(id TEXT PRIMARY KEY,name TEXT NOT NULL);
      PRAGMA user_version=1;
      COMMIT;`);
  }
  async save(house: House) {
    await this.db.run('INSERT INTO houses(id,name) VALUES (?,?)', house.id, house.name);
  }
  all() { return this.db.all<House>('SELECT id,name FROM houses ORDER BY name,id'); }
}
export class HouseService {
  constructor(private repository: Repository, private id: () => string) {}
  async create(input: string) {
    const name = input.trim();
    if (!name) throw new Error('Name required');
    await this.repository.save({ id: this.id(), name });
  }
}

03Expo SQLite anschließen

Der Bildschirm öffnet eine eigene Lerndatei. Der Adapter übersetzt exec, run und all auf Expo-Methoden. Erst nach migrate darf die Liste benutzt werden. Beim Abmelden des Bildschirms wird die Verbindung geschlossen.

app/index.tsx · tsxDatei öffnen
import React, { useEffect, useState } from 'react';
import { View, Text, TextInput, Pressable } from 'react-native';
import * as SQLite from 'expo-sqlite';
import * as Crypto from 'expo-crypto';
import { Repository, HouseService, type House } from '../src/repository';
export default function Screen() {
  const [repository, setRepository] = useState<Repository | null>(null);
  const [rows, setRows] = useState<House[]>([]);
  const [name, setName] = useState(''); const [error, setError] = useState('');
  const [busy, setBusy] = useState(false);
  useEffect(() => {
    let alive = true; let database: SQLite.SQLiteDatabase | undefined;
    const startup = (async () => {
      try {
        database = await SQLite.openDatabaseAsync('house_learning.sqlite');
        const db = database;
        const repo = new Repository({
          exec: sql => db.execAsync(sql),
          run: async (sql, ...values) => { await db.runAsync(sql, ...values); },
          all: (sql, ...values) => db.getAllAsync(sql, ...values),
        });
        await repo.migrate(); const data = await repo.all();
        if (alive) { setRepository(repo); setRows(data); }
        else await db.closeAsync();
      } catch (_) { if (alive) setError('Cannot open storage'); }
    })();
    return () => { alive = false; void startup.finally(() => database?.closeAsync()).catch(() => undefined); };
  }, []);
  async function save() {
    if (!repository || busy) return;
    setBusy(true); setError('');
    try {
      await new HouseService(repository, Crypto.randomUUID).create(name);
      setRows(await repository.all()); setName('');
    } catch (_) { setError('Check name or storage'); }
    finally { setBusy(false); }
  }
  return <View style={{ padding: 24, gap: 16 }}>
    <Text>House learning</Text>
    <TextInput value={name} onChangeText={setName} accessibilityLabel="House name" />
    <Pressable disabled={!repository || busy} accessibilityRole="button" onPress={save}><Text>Save</Text></Pressable>
    <Text>{error}</Text>{rows.map(row => <Text key={row.id}>{row.name}</Text>)}
  </View>;
}

Starten / prüfen

npx expo start

04Entwürfe bei Fehlern erhalten

busy verhindert eine zweite gleichzeitige Aktion dieses Bildschirms. Nach erfolgreichem Schreiben werden Daten neu gelesen und erst dann der Eingabetext geleert. Bei einem Fehler bleibt der Entwurf erhalten. Die echte Hausakte verwaltet mehrere Entwürfe und eine eigene SerialQueue.

05Den echten SQL-Vertrag testen

Der Test verbindet denselben Repository-Code mit node:sqlite. Er schließt die Datei, öffnet sie neu und prüft den Eintrag. Ungültige Namen dürfen keinen weiteren Datensatz erzeugen. Damit wird mehr geprüft als eine Liste in einem Fake.

tests/repository.test.ts · typescriptDatei öffnen
import { test, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Repository, HouseService, type SqlPort } from '../src/repository';
function port(db: DatabaseSync): SqlPort {
  return { exec: async sql => { db.exec(sql); },
    run: async (sql, ...values) => { db.prepare(sql).run(...values); },
    all: async <T>(sql: string, ...values: (string | number)[]) => db.prepare(sql).all(...values) as T[] };
}
test('same SQL repository retains confirmed data across reopen', async () => {
  const directory = mkdtempSync(join(tmpdir(), 'house_learning_'));
  const file = join(directory, 'data.sqlite');
  let db = new DatabaseSync(file);
  try {
    let repo = new Repository(port(db)); await repo.migrate();
    const service = new HouseService(repo, () => 'h1');
    await service.create(' Home ');
    await expect(service.create('  ')).rejects.toThrow('Name required');
    db.close(); db = new DatabaseSync(file); repo = new Repository(port(db));
    await repo.migrate(); expect(await repo.all()).toEqual([{ id: 'h1', name: 'Home' }]);
  } finally { db.close(); rmSync(directory, { recursive: true }); }
});

Starten / prüfen

npx vitest run tests/repository.test.ts
Erwartetes Ergebnis

Home bleibt nach erneutem Öffnen erhalten. Ein leerer Name scheitert, ohne neue Daten zu schreiben.

06Anhänge als eigene Ressource ergänzen

Die vollständige Hausakte speichert Dateien im FileVault und referenziert sie aus Datensätzen. cleanup fragt alle Referenzen ab, bevor eine Datei gelöscht wird. Scheitert diese Abfrage, wird nicht auf Verdacht gelöscht. Ein PDF- oder Backupablauf benötigt deshalb zusätzliche Regeln über die reine SQL-Transaktion hinaus.

Datenfluss
  1. React-Native-Entwurf
  2. HouseService
  3. Repository / SqlPort
  4. Expo SQLite oder Node SQLite
  5. bestätigte Ansicht

Deine Übung

Ergänze eine Eintragstabelle mit house_id als Fremdschlüssel. Prüfe einen unbekannten Bezug und einen gemeinsamen Rollback.

Lösung mit Erklärung anzeigen

Erweitere das Schema in einer neuen Migration. Aktivierte Fremdschlüssel müssen einen missing-Verweis abweisen. Ein Test beginnt eine Transaktion, schreibt Haus und ungültigen Eintrag und rollt anschließend zurück. Beide Änderungen müssen fehlen. Für reale Attachments ergänze FileVault-Referenzprüfungen statt eine automatische gemeinsame Dateitransaktion anzunehmen.

Projektbezug und Quellen

Das Beispiel ist zum Lernen vereinfacht. Die verlinkte Codeversion zeigt die Anwendung im Projekt und die dortigen Prüfungen.