Fast Manual Entry: Reducing Friction Given the Real Deadline

Personal Catalogue: React & Firebase

Chapter 8 · Fast Manual Entry: Reducing Friction Given the Real Deadline

Chapter 1's own real deadline has shaped quiet decisions throughout — no server to write and deploy, a live-listener list instead of manual refresh wiring. This chapter cashes that framing out directly against the add-item flow itself: real friction points, one of them hiding a genuine race condition that turns out to have nothing to do with which database sits underneath it.

One Piece of Friction This Course Never Actually Had

The MongoDB sibling's own Chapter 8 opens by replacing a blocking alert() call with inline error state. This course's own Chapter 4 was built with inline error state from the very first version of handleSubmit — there was never an alert() here to remove. Worth stating plainly rather than silently skipping: not every friction point named for one variant necessarily exists in another.

Returning Focus to the Title Field After a Successful Add

Chapter 4 already resets shared and typeFields after a successful write, and itemType is deliberately never reset — adding five books in a row never needs reselecting "Book" each time. The one piece still missing is the cursor: after a successful add, focus is left wherever it happened to be, 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 addDoc() and state reset: setShared({ title: '', releaseYear: '', notes: '' }); setTypeFields({}); titleInputRef.current?.focus();

The title input itself gets ref={titleInputRef}. A whole shelf of books can now be typed in with barely a pause between submissions.

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 reusing Chapter 6's own searchItems() — built for a different purpose there, general search — catches this as the title is typed, as a soft warning rather than a hard block, since owning two real copies of the same title is a genuinely valid thing to catalogue.

A Naive Live Check — the Same Real Race Condition

A first, reasonable-looking version calls searchItems() on every keystroke, with no debounce and no ordering guarantee:

// A naive duplicate check — fires immediately on every keystroke import { useState, useEffect } from 'react'; import { searchItems } from '../search'; function useDuplicateCheckNaive(title) { const [match, setMatch] = useState(null); useEffect(() => { const trimmed = title.trim(); if (trimmed.length < 3) { setMatch(null); return; } searchItems(trimmed).then((results) => { const exact = results.find((r) => r.title.toLowerCase() === trimmed.toLowerCase()); setMatch(exact || null); }); }, [title]); return match; }

With a real Book titled exactly "Dune" already saved, typing "Dune Messiah" reproduces the identical race the MongoDB sibling found — nothing about getDocs() guarantees two independent Firestore reads resolve in the order they were sent, any more than two independent fetch() calls would:

TimeInput so farCall firedResponse arrivesDisplayed warning
t0"Dune"searchItems("Dune") — will find the real, existing "Dune" book—none yet
t1"Dune Messiah"searchItems("Dune Messiah") — no exact match existsThe "Dune Messiah" call resolves first: no matchnone shown — correct, for the moment
t2(still "Dune Messiah")—The earlier "Dune" call finally resolves, late⚠ "Dune" already exists — shown even though the field now reads "Dune Messiah"
Not a MongoDB-Specific Bug — a Generic Async JavaScript One
Nothing about this race depends on Express, MongoDB, or REST specifically. Two independent asynchronous calls — a fetch() to an API, or a getDocs() call straight to Firestore, makes no difference — carry no guarantee about which one's response lands first. The MongoDB sibling's own version of this bug and this one share the exact same root cause and need the exact same fix, because the cause was never about either database in the first place.

The Fix: Debouncing and Ignoring Stale Responses

// src/hooks/useDuplicateCheck.js import { useState, useEffect, useRef } from 'react'; import { searchItems } from '../search'; 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 results = await searchItems(trimmed); // Ignore this response if the title has moved on since this call was made 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 call fires until typing actually pauses, already making the race far less likely. The real guarantee is latestQuery — when the stale "Dune" response does eventually arrive, its own captured trimmed value no longer matches latestQuery.current (by then "Dune Messiah"), so it's discarded before it can ever reach setMatch().

The Debounce Is Doing Double Duty Here
In the MongoDB sibling, the 400ms delay exists purely to reduce how often the race can occur. Here it does that too, but it's also directly closing the real, metered-read concern Chapter 6 already raised: without it, every keystroke would trigger its own full getDocs() call reading the entire collection, a real Firestore read cost multiplied by however many characters get typed. The same one delay is fixing a correctness problem and a cost problem at once.

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 — purely informational, matching the real decision that owning two copies of the same title is a genuinely valid thing to catalogue, not an error to prevent.

What This Chapter Deliberately Didn't Add

No bulk-import feature — a genuinely bigger piece of scope than "reducing friction in the existing flow" calls for. No extra success toast either, and the reason is arguably even stronger here than for the MongoDB sibling: Chapter 7's own onSnapshot() listener already shows a newly created item in the list the instant Firestore confirms the write, with no manual state update needed to make that happen — a second, separate confirmation message would just repeat what's already visibly true on screen.

Trying It in the Browser

With a real Book titled "Dune" already in the catalogue, type "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. Submitting a deliberately invalid item now shows the error inline, with the rest of the form still fully usable while it's visible — and adding several valid books in a row never needs the mouse between submissions.

Hands-On Exercises

Exercise 1

Add the titleInputRef focus behavior to AddItemForm, then add several valid books in a row without touching the mouse between submissions, confirming the cursor is already in the title field each time.

📄 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 — then explain why this same fix also reduces this chapter's own real Firestore read cost, not just the race condition.

📄 View solution

Chapter 8 Quick Reference

  • No alert() to remove — this course's own Chapter 4 always used inline error state; one friction point the MongoDB sibling had that this course never did
  • 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 searchItems() for a new purpose, as a soft, non-blocking warning
  • The race condition — confirmed to be a generic async-JavaScript problem, not a MongoDB-specific one; the identical bug and the identical fix apply here
  • Debounce + latestQuery guard — debounce reduces how often competing calls exist AND cuts real Firestore read costs; the ref check is what actually guarantees a stale response is ignored
  • Deliberately not added — bulk import, an extra success toast (Chapter 7's own live listener already provides that feedback automatically)
  • Next chapter: Deployment