← React / TypeScript

React / TypeScript · 17 / 18

Persistent optimistic UI, replay and conflicts

Persist intent before sending and prevent duplicate effects when a response is lost.

  • Large
  • Advanced
  • 120 min

Prerequisites: A small offline house log with React Native

Optimistic UI displays a desired change before server confirmation. If kept only in React state, the change disappears on reload. A persistent outbox first stores intent in IndexedDB. A stable command key identifies the same intent during retries. The learning server stores the effect and replay result together in SQLite. The example deliberately permits one outstanding command per interface. React Resilience Lab adds a broader queue, reconnection and accessible conflict decisions.

01Start browser and learning server

Use the React project from tutorial 10. Replace App.tsx and add outbox.ts and server.mjs. Start node server.mjs and npm run dev in separate terminals. Both process only local sample data. The learning server has no authentication and is not a production API.

server.mjs · javascriptOpen file
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'));

Run / check

node server.mjs

02Persist intent first

write waits for transaction.oncomplete rather than only one successful IndexedDB request. The optimistic projection appears only afterwards. A storage failure must not create apparently saved intent. Confirmed server state and outstanding intent remain separately visible.

src/outbox.ts · typescriptOpen file
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';

03Derive React state from the outbox

After reload, the interface reads outstanding commands again. Visible intent comes from that persisted source. Replay removes a command only after a confirmed response. Cancellation or network failure leaves it intact.

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

04Reproduce a lost response

Store a title, enable Fail first reply and click Replay. The server commits the change and deliberately returns an empty failure response with status 503. The client retains the command. Click Replay again. The same key returns the stored result without incrementing the version again. Merely closing the connection would be a less reliable learning simulation because browsers may automatically retry a request.

Expected result

After a lost response, Pending: 1 remains. After replay, Pending: 0 appears and the server version has increased only once.

05Handle a real conflict

Also open the page in a private browser window with separate IndexedDB. Confirm another change there. Replaying the old command returns 412. Compare server and desired titles. Apply mine creates a new key with the current version only after an explicit decision. That is new intent rather than unchanged replay.

06Compare with the real resilience architecture

Read incident-outbox.ts and its tests. The real project adds API contracts, cancellation, persistence migrations, retry rules and conflict focus. Multiple tabs require coordination. Our small interface isolates persistence, idempotency and a version precondition rather than claiming all those guarantees.

Data flow
  1. User intent
  2. IndexedDB commit
  3. Optimistic projection
  4. HTTP with stable key
  5. Server transaction / acknowledgement

Your exercise

Test a server restart after a lost response. Can the same command still be safely retried? Also check the same key with a changed title.

Show the solution and explanation

The SQLite file stores state and replay together. After restart, the same key returns the same persisted success. A changed title mismatches the fingerprint and produces 409. A complete test checks response, version number and the retained client command.

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.