← React / TypeScript

React / TypeScript · 17 / 18

Persistente Optimistic UI, Replay und Konflikte

Du speicherst eine Absicht vor dem Senden und verhinderst doppelte Wirkung bei einer verlorenen Antwort.

  • Groß
  • Anspruchsvoll
  • 120 min

Voraussetzungen: Eine kleine Offline-Hausakte mit React Native

Optimistic UI zeigt eine gewünschte Änderung, bevor der Server sie bestätigt. Wird die Änderung nur im React-State gehalten, verschwindet sie beim Neuladen. Eine persistente Outbox speichert die Absicht zuerst in IndexedDB. Ein stabiler Command-Key identifiziert dieselbe Absicht bei Wiederholungen. Der Lernserver speichert Wirkung und Replay-Ergebnis gemeinsam in SQLite. Das Beispiel erlaubt bewusst nur einen offenen Befehl pro Oberfläche. React Resilience Lab ergänzt eine umfassendere Queue, Wiederverbindung und zugängliche Konfliktentscheidungen.

01Browser und Lernserver starten

Verwende das React-Projekt aus Tutorial 10. Ersetze App.tsx und ergänze outbox.ts sowie server.mjs. Starte node server.mjs und npm run dev in getrennten Terminals. Beide verarbeiten ausschließlich lokale Beispieldaten. Der Lernserver besitzt keine Anmeldung und ist kein Produktions-API.

server.mjs · javascriptDatei öffnen
import http from 'node:http';
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync('outbox-learning.sqlite');
db.exec(`CREATE TABLE IF NOT EXISTS state(id INTEGER PRIMARY KEY, title TEXT NOT NULL,version INTEGER NOT NULL);
 INSERT OR IGNORE INTO state VALUES(1,'Initial',0);
 CREATE TABLE IF NOT EXISTS replay(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,response TEXT NOT NULL);`);
const state = () => db.prepare('SELECT title,version FROM state WHERE id=1').get();
http.createServer(async (request, response) => {
  response.setHeader('Access-Control-Allow-Origin', '*');
  response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
  const send = (status, value) => { response.writeHead(status, {'Content-Type':'application/json'}); response.end(JSON.stringify(value)); };
  if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return; }
  if (request.method === 'GET') { send(200, state()); return; }
  if (request.method !== 'POST') { send(405, state()); return; }
  try {
    let body = '';
    for await (const chunk of request) { body += chunk; if (body.length > 16384) throw new Error('Too large'); }
    const command = JSON.parse(body);
    if (typeof command.id !== 'string' || !command.id || typeof command.title !== 'string' ||
        !command.title.trim() || !Number.isSafeInteger(command.version)) { send(400, state()); return; }
    const fingerprint = JSON.stringify([command.title, command.version]);
    db.exec('BEGIN IMMEDIATE');
    try {
      const previous = db.prepare('SELECT fingerprint,response FROM replay WHERE id=?').get(command.id);
      if (previous) {
        db.exec('ROLLBACK');
        send(previous.fingerprint === fingerprint ? 200 : 409,
          previous.fingerprint === fingerprint ? JSON.parse(previous.response) : state()); return;
      }
      if (state().version !== command.version) { db.exec('ROLLBACK'); send(412, state()); return; }
      db.prepare('UPDATE state SET title=?,version=version+1 WHERE id=1').run(command.title);
      const result = state();
      db.prepare('INSERT INTO replay VALUES(?,?,?)').run(command.id, fingerprint, JSON.stringify(result));
      db.exec('COMMIT');
      if (request.url.includes('lose-response=1')) { response.writeHead(503, { "Content-Type": "application/json" });
        response.end(); return; }
      send(200, result);
    } catch (error) { db.exec('ROLLBACK'); throw error; }
  } catch (_) { send(400, state()); }
}).listen(8283, '127.0.0.1', () => console.log('Learning API: http://127.0.0.1:8283'));

Starten / prüfen

node server.mjs

02Die Absicht zuerst dauerhaft speichern

write wartet auf transaction.oncomplete, nicht nur auf den Erfolg eines einzelnen IndexedDB-Requests. Erst danach erscheint die optimistic Projektion. Ein Speicherfehler darf keinen scheinbar gesicherten Wunsch erzeugen. Der bestätigte Serverstand und der offene Wunsch bleiben getrennt sichtbar.

src/outbox.ts · typescriptDatei öffnen
export type Command = { id: string; title: string; version: number };
export type State = { title: string; version: number };
const database = new Promise<IDBDatabase>((resolve, reject) => {
  const request = indexedDB.open('appfabrik-outbox-learning', 1);
  request.onupgradeneeded = () => request.result.createObjectStore('commands', { keyPath: 'id' });
  request.onsuccess = () => resolve(request.result);
  request.onerror = () => reject(request.error);
});
export async function pending(): Promise<Command[]> {
  const db = await database;
  return new Promise((resolve, reject) => {
    const request = db.transaction('commands').objectStore('commands').getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}
export async function write(command: Command | null, remove?: string) {
  const db = await database;
  await new Promise<void>((resolve, reject) => {
    const transaction = db.transaction('commands', 'readwrite');
    const store = transaction.objectStore('commands');
    if (remove) store.delete(remove);
    if (command) store.put(command);
    transaction.oncomplete = () => resolve();
    transaction.onerror = () => reject(transaction.error);
    transaction.onabort = () => reject(transaction.error);
  });
}
export function parseState(value: unknown): State {
  if (typeof value !== 'object' || value === null || !('title' in value) ||
      typeof value.title !== 'string' || !('version' in value) ||
      typeof value.version !== 'number' || !Number.isSafeInteger(value.version)) {
    throw new Error('Invalid server state');
  }
  return { title: value.title, version: value.version };
}
export const api = 'http://127.0.0.1:8283';

03Den React-Zustand aus der Outbox ableiten

Nach dem Neuladen liest die Oberfläche ihre offenen Commands erneut. Der sichtbare Wunsch stammt aus dieser gespeicherten Quelle. Replay entfernt einen Command erst nach einer bestätigten Antwort. Abbruch oder Netzwerkfehler lassen ihn bestehen.

src/App.tsx · tsxDatei öffnen
import React, { useEffect, useState } from 'react';
import { api, pending, write, parseState, type Command, type State } from './outbox';
export function App() {
  const [confirmed, setConfirmed] = useState<State>({ title: 'Initial', version: 0 });
  const [commands, setCommands] = useState<Command[]>([]);
  const [title, setTitle] = useState(''); const [message, setMessage] = useState('');
  const [busy, setBusy] = useState(false); const [lose, setLose] = useState(false);
  async function refresh() {
    setCommands(await pending());
    try { setConfirmed(parseState(await (await fetch(api)).json())); }
    catch (_) { setMessage('Offline: pending intent is retained'); }
  }
  useEffect(() => { void refresh(); }, []);
  async function save() {
    if (!title.trim() || commands.length || busy) return;
    setBusy(true);
    try {
      await write({ id: crypto.randomUUID(), title: title.trim(), version: confirmed.version });
      setCommands(await pending()); setTitle(''); setMessage('Intent stored before sending');
    } catch (_) { setMessage('Cannot persist intent'); }
    finally { setBusy(false); }
  }
  async function replay() {
    if (busy) return; setBusy(true);
    try {
      for (const command of await pending()) {
        const response = await fetch(api + (lose ? '?lose-response=1' : ''), {
          method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command),
        });
        const state = parseState(await response.json());
        setConfirmed(state);
        if (response.status === 412) { setMessage('Conflict: compare server and pending title'); return; }
        if (!response.ok) throw new Error('Command rejected');
        await write(null, command.id);
      }
      setMessage('Confirmed');
    } catch (_) { setMessage('Reply missing: retry the same stored command'); }
    finally { setCommands(await pending()); setBusy(false); }
  }
  async function rebase() {
    if (!commands.length || busy) return;
    const old = commands[0];
    await write({ ...old, id: crypto.randomUUID(), version: confirmed.version }, old.id);
    setCommands(await pending()); setMessage('New intent stored against current version');
  }
  return <main><h1>Durable intent</h1>
    <p>Server: {confirmed.title}, version {confirmed.version}</p>
    <p>Optimistic: {commands[0]?.title ?? confirmed.title}</p>
    <label>Title<input value={title} onChange={event => setTitle(event.target.value)} /></label>
    <button disabled={busy || commands.length > 0} onClick={() => void save()}>Store intent</button>
    <button disabled={busy || !commands.length} onClick={() => void replay()}>Replay</button>
    <label><input type="checkbox" checked={lose} onChange={event => setLose(event.target.checked)} />Fail first reply</label>
    <button disabled={busy || !commands.length} onClick={() => void rebase()}>Apply mine to current version</button>
    <p role="status">{message}</p><p>Pending: {commands.length}</p>
  </main>;
}

04Eine verlorene Antwort reproduzieren

Speichere einen Titel, aktiviere Fail first reply und klicke Replay. Der Server committed die Änderung und liefert danach absichtlich eine leere Fehlerantwort mit Status 503. Der Client behält den Command. Klicke erneut Replay. Der gleiche Key liefert das gespeicherte Ergebnis, ohne die Version erneut zu erhöhen. Ein bloßes Schließen der Verbindung wäre als Lernsimulation weniger verlässlich, weil Browser eine Anfrage automatisch wiederholen können.

Erwartetes Ergebnis

Nach verlorener Antwort bleibt Pending: 1. Nach Replay steht Pending: 0 und die Serverversion wurde nur einmal erhöht.

05Einen echten Konflikt behandeln

Öffne die Seite zusätzlich in einem privaten Browserfenster mit eigener IndexedDB. Bestätige dort eine andere Änderung. Replay des alten Commands liefert 412. Vergleiche Server- und Wunschtitel. Apply mine erzeugt erst nach der ausdrücklichen Entscheidung einen neuen Key mit aktueller Version. Das ist eine neue Absicht, kein unverändertes Replay.

06Mit der echten Resilience-Architektur vergleichen

Lies incident-outbox.ts und die zugehörigen Tests. Das echte Projekt ergänzt API-Verträge, Cancellation, Persistenzmigrationen, Retryregeln und Konfliktfokus. Mehrere Tabs benötigen Koordination. Unsere kleine Oberfläche isoliert Persistenz, Idempotenz und eine Versionsvorbedingung, statt bereits alle diese Garantien zu behaupten.

Datenfluss
  1. Nutzerabsicht
  2. IndexedDB-Commit
  3. optimistische Projektion
  4. HTTP mit stabilem Key
  5. Servertransaktion / Bestätigung

Deine Übung

Teste einen Serverneustart nach der verlorenen Antwort. Kann derselbe Command weiter sicher wiederholt werden? Prüfe außerdem denselben Key mit verändertem Titel.

Lösung mit Erklärung anzeigen

Die SQLite-Datei hält state und replay gemeinsam. Nach dem Neustart liefert derselbe Key denselben gespeicherten Erfolg. Bei geändertem Titel passt der Fingerprint nicht und der Server liefert 409. Ein vollständiger Test prüft Response, Versionszahl und erhaltenen Client-Command.

Projektbezug und Quellen

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