← React / TypeScript

React / TypeScript · 16 / 18

A small offline house log with React Native

Connect native input, a service and a SQLite repository replaceable between Android and tests.

  • Large
  • Advanced
  • 120 min

Prerequisites: Test components and repositories meaningfully

An offline app needs no server for its core task, but still needs reliable local storage and clear ownership boundaries. We build a small house list with normalised names. A SqlPort separates Expo SQLite from Node SQLite. The same repository code therefore runs on device and in tests. The learning product is deliberately smaller than House Log. Photos, PDFs, backups and reminders are additional workflows built on the same boundaries.

01Prepare the independent learning app

Use the Expo SDK 57 learning app from tutorial 12. Add npx expo install expo-sqlite expo-crypto and npm install --save-dev vitest@4.1.10 @types/node. Save all sample files. If routes live under src/app, adjust only the relative repository import.

02Implement the domain rule and port

Repository knows only three SQL operations. HouseService also receives an ID generator. The domain rule therefore depends on neither Expo nor Node. The UUID is created once per confirmed creation.

src/repository.ts · typescriptOpen file
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 });
  }
}

03Connect Expo SQLite

The screen opens its own learning file. The adapter translates exec, run and all into Expo methods. The list is usable only after migrate. The connection closes when the screen is removed.

app/index.tsx · tsxOpen file
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>;
}

Run / check

npx expo start

04Retain drafts on failure

busy prevents a second concurrent action on this screen. After successful writing, data is read again and only then is input cleared. Failure preserves the draft. The real House Log manages multiple drafts and its own SerialQueue.

05Test the actual SQL contract

The test connects the same repository code to node:sqlite. It closes the file, reopens it and checks the record. Invalid names must create no additional record. This verifies more than an array inside a fake.

tests/repository.test.ts · typescriptOpen file
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 }); }
});

Run / check

npx vitest run tests/repository.test.ts
Expected result

Home survives reopening. A blank name fails without writing new data.

06Add attachments as separate resources

The complete House Log keeps files in FileVault and references them from records. cleanup checks every reference before deleting a file. If lookup fails, deletion is not guessed. PDF and backup workflows therefore need rules beyond the SQL transaction.

Data flow
  1. React Native draft
  2. HouseService
  3. Repository / SqlPort
  4. Expo SQLite or Node SQLite
  5. confirmed view

Your exercise

Add an entries table with house_id as a foreign key. Test an unknown reference and a shared rollback.

Show the solution and explanation

Extend the schema through a new migration. Enabled foreign keys must reject a missing reference. A test starts a transaction, writes a house and an invalid entry, then rolls back. Both changes must be absent. Real attachments also need FileVault reference checks rather than an assumed automatic file transaction.

Project context and sources

The example is simplified for learning. The linked source version shows its use in the project and the checks applied there.