Keep browser drafts, saved data and search results consistentLESSON 6.01 · 1 OF 7 IN CHAPTER
PART B / Frontend state and API integration
Step 106 of 252
LESSON 6.01 · 1 OF 7 IN CHAPTERGUIDED READING

Keep browser drafts, saved data and search results consistent

Alice uses a reading-list app to save documentation links for her team. The browser shows bookmarks returned by an HTTP API. Alice can search the list and edit a bookmark's display title. The database stores accepted changes, while the browser also holds text she has typed but has not saved yet.

Those two versions can differ legitimately. Alice saves “Database setup guide,” then keeps typing “— team notes” while the response is delayed. The server has accepted the earlier title. The text field must still contain her newer draft.

This lesson explains how to represent that situation, handle responses arriving out of order, and make the state understandable through the interface. You can follow the examples first, then run the supplied bookmark editor. It includes the HTML, TypeScript, HTTP server and SQLite storage. Its demo identity is local-only. You do not need an AWS account for this lesson.

See the interface and the state it represents

This is a screenshot of the supplied editor after the earlier save completed:

The actual editor shows server title Database setup guide at version 2, retains Database setup guide — team notes in the focused title input, and says the newer draft is unsaved.

Read the screen as three separate facts. Server describes the confirmed database record. Title contains Alice's current draft. The status sentence explains why they differ. “Saved” alone would be misleading because the visible text has not all been saved.

Fact Example Who may change it?
Confirmed record Database setup guide, version 2 A successful authorized server operation
Current draft Database setup guide — team notes Alice's typing or an explicit discard/replace action
Pending save Snapshot of the title sent with its expected version and mutation key The save coordinator, until the operation resolves
Editor lifetime Which bookmark is currently open Selecting or closing the editor

A mutation is an operation that changes server data. Its key identifies this particular save, so retrying a lost response can refer to the same operation. A version identifies the server record being edited. These are different identities.

Follow a save while the user keeps typing

Event Confirmed title Browser draft Visible status
Open the bookmark Original title, v1 Original title Ready to edit
Type A and press Save Original title, v1 A Saving
Type B while A is in flight Original title, v1 B Saving the earlier edit. Newer draft retained
Server confirms A/v2 A, v2 B Earlier edit saved. Newer draft is unsaved

The implementation captures a draft revision when sending the request. A revision is a counter incremented on each local edit. When the save returns, it replaces the draft only if no newer local edit exists.

This excerpt from the supplied TypeScript (download file, source below) shows that decision. The surrounding handler also checks editor lifetime, mutation identity and response validity:

Read the supplied code · app.ts
supplied TypeScript · app.ts
type Bookmark = {id: string; title: string; url: string; created_at: number; version: number};
export {}; // This file is loaded as an ES module in index.html.
type Page = {items: Bookmark[]; nextCursor: string | null};
type Pending = {key: string; title: string; revision: number; expectedVersion: number; generation: number};
const get = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
const title = get<HTMLInputElement>('title');
const save = get<HTMLButtonElement>('save');
const status = get<HTMLParagraphElement>('edit-status');
const headers = {Authorization: 'Bearer alice-local-token'};

function bookmark(value: unknown): Bookmark {
  const x = value as Partial<Bookmark> | null;
  if (!x || typeof x.id !== 'string' || typeof x.title !== 'string' || typeof x.url !== 'string' ||
      !Number.isSafeInteger(x.version) || Number(x.version) < 1 || !Number.isSafeInteger(x.created_at)) {
    throw new Error('Invalid bookmark response');
  }
  return x as Bookmark;
}
function page(value: unknown): Page {
  const x = value as Partial<Page> | null;
  if (!x || !Array.isArray(x.items) || (x.nextCursor !== null && typeof x.nextCursor !== 'string')) {
    throw new Error('Invalid list response');
  }
  return {items: x.items.map(bookmark), nextCursor: x.nextCursor};
}

let confirmed: Bookmark | null = null;
let draft = '';
let revision = 0;
let generation = 0; // changes on select/dispose; server versions are a separate ordering
let pending: Pending | null = null;
let saving = false;
let conflict = false;
let editorController: AbortController | null = null;
let opener: HTMLButtonElement | null = null;

function paintEditor(message?: string): void {
  get('editor').hidden = confirmed === null;
  if (!confirmed) return;
  title.value = draft;
  get('confirmed').textContent = `Server: ${confirmed.title} · version ${confirmed.version}`;
  save.disabled = saving || conflict || !draft.trim() || (!pending && draft === confirmed.title);
  save.textContent = saving ? 'Saving…' : pending ? 'Retry save' : 'Save title';
  get('resolve').hidden = !conflict;
  if (message !== undefined) status.textContent = message;
}

function openEditor(row: Bookmark, source: HTMLButtonElement): void {
  editorController?.abort();
  editorController = new AbortController();
  generation++;
  confirmed = row;
  draft = row.title;
  revision = 0;
  pending = null;
  saving = false;
  conflict = false;
  opener = source;
  paintEditor('Ready to edit.');
  title.focus();
}

function closeEditor(): void {
  generation++;
  editorController?.abort();
  confirmed = null;
  pending = null;
  saving = false;
  paintEditor();
  if (opener?.isConnected) opener.focus();
  else get('search').focus();
}

title.addEventListener('input', () => {
  draft = title.value;
  revision++;
  paintEditor(conflict ? 'Conflict. Your draft is retained. Choose the server version before saving.' :
              saving ? 'Saving the earlier edit. Your newer draft is retained.' : 'Unsaved changes.');
});

get('edit-form').addEventListener('submit', async event => {
  event.preventDefault();
  if (!confirmed || saving || conflict || !draft.trim()) return;
  // Retrying an uncertain result reuses A's exact payload and key, even after B is typed.
  pending ??= {key: crypto.randomUUID(), title: draft, revision, expectedVersion: confirmed.version, generation};
  const mutation = pending;
  const id = confirmed.id;
  saving = true;
  paintEditor('Saving…');
  try {
    const response = await fetch(`/api/bookmarks/${encodeURIComponent(id)}`, {
      method: 'PATCH', signal: editorController?.signal,
      headers: {...headers, 'Content-Type': 'application/json', 'Idempotency-Key': mutation.key},
      body: JSON.stringify({title: mutation.title, expectedVersion: mutation.expectedVersion})
    });
    const body: unknown = await response.json();
    if (generation !== mutation.generation || pending?.key !== mutation.key || !confirmed) return;
    if (response.status === 409) {
      const current = bookmark((body as {current: unknown}).current);
      if (current.id !== id) throw new Error('Mismatched bookmark response');
      if (current.version >= confirmed.version) confirmed = current;
      pending = null;
      saving = false;
      conflict = true;
      paintEditor('Conflict. Your draft is retained. Review the server copy, then keep your draft.');
      get('resolve').focus();
      return;
    }
    if (!response.ok) throw new Error(`Save failed (${response.status}).`);
    const current = bookmark(body);
    if (current.id !== id) throw new Error('Mismatched bookmark response');
    if (current.version >= confirmed.version) confirmed = current;
    if (revision === mutation.revision) draft = confirmed.title;
    pending = null;
    saving = false;
    paintEditor(draft === confirmed.title ? 'Saved.' : 'Earlier edit saved. Your newer draft is unsaved.');
  } catch (error) {
    if (generation !== mutation.generation || !confirmed) return;
    saving = false;
    paintEditor('Save outcome unavailable. Your draft is retained. Retry uses the same mutation.');
  }
});

get('resolve').addEventListener('click', () => {
  conflict = false;
  paintEditor('Draft retained. Save will use the displayed server version.');
  title.focus();
});
get('close').addEventListener('click', closeEditor);
get('refresh').addEventListener('click', async () => {
  if (!confirmed) return;
  const mine = generation, id = confirmed.id;
  try {
    const response = await fetch(`/api/bookmarks/${encodeURIComponent(id)}`, {headers, signal: editorController?.signal});
    if (!response.ok) throw new Error('Refresh failed');
    const current = bookmark(await response.json());
    if (mine !== generation || !confirmed || current.id !== id) return;
    const dirty = draft !== confirmed.title;
    if (current.version > confirmed.version) {
      confirmed = current;
      if (!dirty && !pending) draft = current.title;
      paintEditor('Server copy refreshed. Unsaved edits are retained.');
    } else {
      paintEditor('Server copy unchanged. Unsaved edits are retained.');
    }
  } catch {
    if (mine === generation && confirmed) paintEditor('Refresh failed. Your draft is retained; try Refresh again.');
  }
});

let searchGeneration = 0;
let listController: AbortController | null = null;
let nextCursor: string | null = null;
let currentQuery = '';
let rows: Bookmark[] = [];
let failedAppend = false;
async function search(append = false): Promise<void> {
  listController?.abort();
  listController = new AbortController();
  const mine = ++searchGeneration;
  if (!append) {
    currentQuery = get<HTMLInputElement>('search').value;
    // Rows and their cursor belong to one query. A failed new search must never
    // leave an old continuation usable with the new query.
    rows = [];
    nextCursor = null;
    get('results').replaceChildren();
    get('more').hidden = true;
  }
  const params = new URLSearchParams({q: currentQuery, limit: '2'});
  if (append && nextCursor) params.set('cursor', nextCursor);
  get('list-status').textContent = 'Loading…';
  get('retry-list').hidden = true;
  get<HTMLButtonElement>('more').disabled = true;
  try {
    const response = await fetch(`/api/bookmarks?${params}`, {headers, signal: listController.signal});
    if (!response.ok) throw new Error(`Search failed (${response.status})`);
    const data = page(await response.json());
    if (mine !== searchGeneration) return;
    const combined = append ? [...rows, ...data.items] : data.items;
    rows = Array.from(new Map(combined.map(row => [row.id, row])).values());
    nextCursor = data.nextCursor;
    const list = get('results');
    list.replaceChildren();
    for (const row of rows) {
      const item = document.createElement('li');
      const button = document.createElement('button');
      button.textContent = `Edit ${row.title}`;
      button.addEventListener('click', () => openEditor(row, button));
      item.append(button);
      list.append(item);
    }
    get('list-status').textContent = rows.length ? `${rows.length} bookmarks loaded.` : 'No bookmarks found.';
    get('more').hidden = nextCursor === null;
  } catch {
    if (mine !== searchGeneration) return;
    failedAppend = append;
    get('list-status').textContent = 'Search failed. Retry to load bookmarks.';
    get('retry-list').hidden = false;
  } finally {
    if (mine === searchGeneration) get<HTMLButtonElement>('more').disabled = false;
  }
}
get('search-form').addEventListener('submit', event => {event.preventDefault(); void search();});
get('more').addEventListener('click', () => {void search(true);});
get('retry-list').addEventListener('click', () => {void search(failedAppend);});
window.addEventListener('pagehide', () => {generation++; searchGeneration++; editorController?.abort(); listController?.abort();});
void search();
if (current.version >= confirmed.version) confirmed = current;
if (revision === mutation.revision) draft = confirmed.title;
pending = null;
saving = false;
paintEditor(draft === confirmed.title
  ? 'Saved.'
  : 'Earlier edit saved. Your newer draft is unsaved.');

Do not copy only the final assignment into an unrelated component. The guard depends on capturing mutation.revision when sending the save and incrementing revision when the user types.

Try it: run the editor, open a bookmark, change its title and save. Use the browser's network throttling to make the pending state visible, then type another suffix before the response returns. Inspect the server label, input and status separately. The screenshot above was captured with the actual server response deliberately held after its database write, so the ordering was controlled rather than inferred from a fast click.

The supplied editor keeps drafts in memory. Closing the editor or reloading discards unsaved text. Adding a navigation warning or durable local drafts is a separate requirement. Server persistence does not automatically preserve an unsent browser draft.

Let the latest search intent own the results

Alice searches for cat, then corrects it to car. The car response arrives first. The older cat response must not replace it afterward.

Search requests complete in reverse order, with a generation check preventing the older response from replacing the current search.

Give each search intent a generation, an increasing counter. Capture the generation before awaiting the request, then compare it with the current generation before displaying either results or an error.

let generation = 0;

async function runSearch(query: string) {
  const mine = ++generation;
  showLoading(query);
  try {
    const results = await loadResults(query);
    if (mine !== generation) return;
    showResults(results);
  } catch {
    if (mine !== generation) return;
    showError(query);
  }
}

This is explanatory pseudocode with UI helpers, not another supplied application. The search-race lab supplies the focused exercise. Abort obsolete requests where supported to save work, but keep the generation check because cancellation can arrive too late or be ignored. Closing a view must also invalidate outstanding work.

The guard applies to errors too. An old successful request must not clear the error from the current search, and an old error must not replace current results.

Distinguish empty results from unavailable results

An empty result means the API answered successfully and found no matches. An error means the browser could not obtain a trustworthy answer. They need different messages and actions.

Actual bookmark editor with a no-such-bookmark search and the message No bookmarks found.

The query above has no matching rows. The user's next action is to change or clear the query. That is different from the controlled API failure below:

Actual bookmark editor after a controlled API 503 response, showing Search failed and a Retry search button.

The failure screenshot uses the real interface with an intentionally unavailable API response. It demonstrates presentation behavior, not a deployed outage.

State What is known Useful next action
Loading A request is pending Wait briefly or cancel where supported
Empty A successful response contains no matching rows Clear the filter or create an item if supported
Failed The requested result is unknown Retry or retain an explicitly labeled previous result
Loaded A current response contains rows Read, edit or paginate

Choose whether previous results remain visible during refresh. If they do, label them as previous data. Do not silently treat them as the answer to a different query. Bound waiting with a deadline or a clear recovery action.

Put each kind of state where its lifetime belongs

A shared bookmark belongs on the server because other people and devices need it. A shareable search filter can belong in the URL. An unsaved draft belongs to the editing interaction, with a persistence policy chosen deliberately. A tooltip's open state can remain in component memory.

Decision guide for choosing server state, URL state, shared browser state or component memory according to the lifetime and sharing required.

A cache is a reconstructible copy. It may be evicted or refreshed. A draft is new user input that may exist nowhere else. Treating them as interchangeable is how a refresh erases unsaved work.

For a filter stored in the URL, demonstrate refresh, Back and opening the same URL in another tab. For private data, the server still checks the requesting user's permissions. A URL carrying an item ID is not authorization to read that item.

Make the flow usable with a keyboard and assistive technology

Use a real <label> for each input and native buttons for actions. Keep focus visible. When a conflict needs a decision, move focus to a useful control without erasing the draft. Announce asynchronous status through an appropriate live region. The supplied editor uses role="status" for save and search outcomes.

Walk the actual task using Tab, Shift+Tab and Enter. Open an editor, type, save, encounter a conflict, resolve it and close the editor. Focus should return to a sensible place. Keyboard operation is one part of accessibility, not proof of complete conformance.

Measure contrast against the applicable WCAG contrast criteria. Ordinary text generally needs 4.5:1 at level AA, while qualifying large text uses 3:1, with stated exceptions. Inspect labels, focus, status announcements and targets as separate requirements. A generic checklist cannot establish every product's legal obligations.

Choose rendering and performance work from the user journey

Rendering choice Where the HTML is produced A useful fit
Static During the build Guides and other content published with a release
Server-rendered For a request Pages needing current server data before display
Browser-rendered By JavaScript on the device Interactive state after the initial load
Streamed In pieces as work becomes ready A page where useful sections can arrive independently

These can coexist. Rendering on the server does not eliminate browser state, and browser rendering does not move authorization out of the server.

Measure loading, responsiveness and visual stability on representative devices. Core Web Vitals use LCP, INP and CLS, with good thresholds of 2.5 seconds, 200 milliseconds and 0.1 respectively at the 75th percentile. Those page-level measures do not answer whether an edit was lost. Keep correctness and performance evidence separate.

Before adding virtualization or another state library, identify a measured problem: too many rendered rows, an expensive event handler, repeated requests or unclear ownership. Use a bounded list first and record the improvement under the same workload.

Apply the lesson to your reading-list UI

Start with the real bookmark editor to inspect draft and response behavior. For the continuing reading-list project, use its HTTP API starter and build your own list and form. These are two different supplied applications. The editor reference edits seeded bookmarks. The reading-list starter supports creating bookmarks, notes and member reading state but does not include a browser UI.

Your result should show loading, empty, failed and loaded states, preserve newer drafts after an earlier save, and reject obsolete search completions. Keep server validation and ownership checks even when the browser disables a button. A raw HTTP caller can bypass the UI.

Explain one complete save using the browser draft, pending request and confirmed database row. Then change the requirement: the user reloads before saving. Choose a discard warning or durable draft design, state its privacy and expiry behavior, and show what appears on return.

Sources and further reading · 2