Fast Manual Entry

Personal Catalogue: React, Express & MongoDB

Chapter 8 · Fast Manual Entry

Chapter 1's own real deadline shaped a lot of quiet decisions already — a single Express API, Mongoose validation over hand-rolled checks, a deliberately plain form. This chapter cashes that framing out directly: three real points of friction in the add-item flow, one of them hiding a genuine network race condition, all found and fixed with the app exactly as it already stands after Chapter 7.

Blocking Alerts Break a Fast, Several-in-a-Row Workflow

Chapter 4's own error handling calls alert(error) on a failed submission — a real, native browser dialog that has to be dismissed with a click before anything else on the page can be touched again. Cataloguing several items in a row, one validation mistake stops the whole flow cold until that dialog is cleared.

// AddItemForm.jsx — replacing the blocking alert with inline state const [error, setError] = useState(null); async function handleSubmit(e) { e.preventDefault(); setError(null); // ...payload construction unchanged from Chapter 4... const res = await fetch('http://localhost:4000/api/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { const { error: message } = await res.json(); setError(message); return; } // ...success handling unchanged... }

Rendered near the submit button, {error && <p className="form-error">{error}</p>} shows exactly the same message the dialog used to, without ever stopping the rest of the page from responding. A validation error can be read and fixed in place, in the same motion as correcting the field that caused it.

Returning Focus to the Title Field After a Successful Add

Chapter 4 already resets shared and typeFields after a successful submission, and — worth noting explicitly, since it's easy to miss — itemType is deliberately never reset, so adding five books in a row never requires reselecting "Book" from the dropdown each time. The one piece still missing is the cursor itself: after a successful add, focus is left wherever it happened to be, usually on the submit button, so typing the next title means reaching for the mouse first.

// AddItemForm.jsx — a ref on the title input, focused after a successful add import { useState, useRef } from 'react'; const titleInputRef = useRef(null); // inside handleSubmit, after a successful response and state reset: setShared({ title: '', releaseYear: '', notes: '' }); setTypeFields({}); titleInputRef.current?.focus();

The title input itself gets ref={titleInputRef}. After each successful submission, the cursor lands right back in the one field every new item genuinely needs, letting a whole shelf of books get typed in with barely a pause between them.

A New Feature: Warning About Likely Duplicates While Typing

Fast entry has its own real risk: cataloguing quickly makes it easy to add something that's already there without noticing. A live check against Chapter 6's own search route, running as the title is typed, catches this without slowing anything down — a soft warning, not a hard block, since owning two real copies of the same title is a genuinely valid thing to catalogue.

A Naive Live Search — And a Real Race Condition

A first, reasonable-looking version fires a search on every keystroke, no debouncing, no ordering guarantee:

// A naive duplicate check — fires immediately on every keystroke function useDuplicateCheckNaive(title) { const [match, setMatch] = useState(null); useEffect(() => { const trimmed = title.trim(); if (trimmed.length < 3) { setMatch(null); return; } fetch(`http://localhost:4000/api/items/search?q=${encodeURIComponent(trimmed)}`) .then((res) => res.json()) .then((results) => { const exact = results.find((r) => r.title.toLowerCase() === trimmed.toLowerCase()); setMatch(exact || null); }); }, [title]); return match; }

Suppose the catalogue already has a real Book titled exactly "Dune", and someone is typing a genuinely different, new title: "Dune Messiah". Each keystroke fires its own independent request, and nothing about fetch() guarantees those requests resolve in the order they were sent — real network round trips vary in timing for reasons entirely outside this code's control.

TimeInput so farRequest firedResponse arrivesDisplayed warning
t0"Dune"?q=Dune — will find the real, existing "Dune" book—none yet
t1"Dune Messiah"?q=Dune Messiah — no exact match existsThe "Dune Messiah" request resolves first: no matchnone shown — correct, for the moment
t2(still "Dune Messiah")—The earlier "Dune" request finally resolves, late⚠ "Dune" already exists — shown even though the field now reads "Dune Messiah"

The final, visible state is wrong — not because of any real duplicate, but purely because an earlier request's response happened to arrive after a later one's.

The Fix: Debouncing and Ignoring Stale Responses

Two changes, doing two genuinely different jobs. A short delay before firing a request means most in-progress typing never triggers a request at all — only a real pause does. A small ref, checked before ever applying a response, is what actually guarantees correctness regardless of how those requests happen to resolve:

// src/hooks/useDuplicateCheck.js import { useState, useEffect, useRef } from 'react'; export function useDuplicateCheck(title) { const [match, setMatch] = useState(null); const latestQuery = useRef(''); useEffect(() => { const trimmed = title.trim(); latestQuery.current = trimmed; if (trimmed.length < 3) { setMatch(null); return; } const timeoutId = setTimeout(async () => { const res = await fetch(`http://localhost:4000/api/items/search?q=${encodeURIComponent(trimmed)}`); const results = await res.json(); // Ignore this response if the title has moved on since this request was sent if (latestQuery.current !== trimmed) return; const exact = results.find((r) => r.title.toLowerCase() === trimmed.toLowerCase()); setMatch(exact || null); }, 400); return () => clearTimeout(timeoutId); }, [title]); return match; }

Retracing the exact same "Dune" / "Dune Messiah" timeline: the debounce means neither request fires until typing actually pauses, which on its own already makes the specific race far less likely to occur at all. But the real guarantee is the latestQuery check — when the stale "Dune" response does eventually arrive, its own captured trimmed value ("Dune") no longer matches latestQuery.current (which by then holds "Dune Messiah"), so the response is discarded before it can ever reach setMatch().

Debouncing Alone Isn't Enough
Debouncing reduces how often two competing requests can even exist at once — it doesn't guarantee which one's response arrives first when they do. A user who pauses briefly, then types quickly again, can still fire two debounced requests close enough together to race. The latestQuery ref is what makes the result correct regardless of timing, not the delay by itself.

Wiring the Duplicate Warning Into the Form

// AddItemForm.jsx (continued) import { useDuplicateCheck } from '../hooks/useDuplicateCheck'; // inside the component, alongside the other state: const duplicateMatch = useDuplicateCheck(shared.title);
// AddItemForm.jsx (continued) — rendered just below the title input {duplicateMatch && ( <p className="duplicate-warning"> You may already have this — "{duplicateMatch.title}" is already in your catalogue. </p> )}

Nothing about the warning blocks the submit button — it's purely informational, exactly matching the real decision that owning two copies of the same title is a genuine, valid thing to catalogue, not an error to prevent.

What This Chapter Deliberately Didn't Add

No bulk-import or paste-many-titles-at-once feature — a genuinely bigger piece of scope than "reducing friction in the existing flow" calls for, and not what this chapter's own outline promises. No extra success toast either: since the form and the list render on the same screen together (per Chapter 7's own layout), a newly added item already appears immediately below the form the moment it's created — real, visible confirmation that doesn't need a second, separate message layered on top of it.

Trying It in the Browser

With a real Book titled "Dune" already in the catalogue, start typing "Dune Messiah" into the title field at a normal pace and watch the warning appear briefly on "Dune" and then correctly clear itself once the full, different title has settled — the debounce and staleness guard together mean the warning never gets stuck showing a false match. Submitting a deliberately invalid item (an empty required field) now shows the error inline, with the rest of the form still fully usable while it's visible.

Hands-On Exercises

Exercise 1

Replace the alert()-based error handling with inline error state, and add the titleInputRef focus behavior. Submit an invalid Book (missing author) and confirm the error appears inline with the form still usable, then add several valid books in a row without touching the mouse between submissions.

📄 View solution
Exercise 2

Build the naive useDuplicateCheckNaive hook (no debounce, no staleness guard) and reproduce the race condition described in the chapter — with a real "Dune" book already saved, type "Dune Messiah" and observe a false "Dune already exists" warning appearing after the full title has been typed.

📄 View solution
Exercise 3

Replace the naive hook with the fixed useDuplicateCheck (debounced, with the latestQuery staleness guard) and repeat the exact same typing sequence from Exercise 2, confirming the false warning no longer appears.

📄 View solution

Chapter 8 Quick Reference

  • Inline errors, not alert() — a failed submission shows its message in place, without blocking the rest of the form
  • Focus returned to title — a ref-based .focus() call after every successful add, on top of itemType already persisting since Chapter 4
  • Live duplicate check — reuses Chapter 6's own /search route for a new purpose, as a soft, non-blocking warning
  • The race condition — independent fetch requests can resolve out of order; an earlier, now-stale response can overwrite a later, correct one
  • Debounce + latestQuery guard — debounce reduces how often competing requests exist; the ref check is what actually guarantees a stale response is ignored
  • Deliberately not added — bulk import, an extra success toast (the list already provides that feedback)
  • Next chapter: Deployment