Building the Input/Output UI in React

Romaji to Kana Converter: React & Next.js

Chapter 5 · Building the Input/Output UI in React

Chapter 4 closed with a real, measured verdict: the version of this converter that should actually ship calls convert() directly in the browser, with the real /api/convert route kept alive specifically so the two approaches can be compared side by side. This chapter builds both — a client-side box that's the real recommended version, and a second box wired to the API route, deliberately built with the same real request-timing risks a genuine server-calling UI would face in production.

The Client-Side Box: The Version Meant to Ship

Any component using React state or an event handler needs the "use client" directive at the top of its file — by default, every component in the App Router is a Server Component, which can't hold interactive state at all:

// src/app/components/ClientConverter.tsx 'use client'; import { useState } from 'react'; import { convert } from '@/lib/convert'; export default function ClientConverter() { const [input, setInput] = useState(''); const { hiragana, katakana } = convert(input); return ( <div> <h3>Client-Side (Recommended)</h3> <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Type romaji..." /> <p>Hiragana: {hiragana}</p> <p>Katakana: {katakana}</p> </div> ); }

convert(input) runs directly inside the render, on every single keystroke — no useEffect, no debounce, nothing asynchronous at all. That's a deliberate, Chapter-4-backed choice, not an oversight:

No Debounce Needed — Confirmed by Chapter 4's Own Real Numbers
Chapter 4 measured a direct convert() call at well under a microsecond. A real, fast typist producing a keystroke every 50–100 milliseconds is nowhere close to fast enough to make that cost add up to anything worth optimizing away — debouncing a call this cheap would add real complexity to solve a problem that, per Chapter 4's own measured evidence, doesn't exist.

Real, Verified Live-Typing Behavior

Because convert() runs against whatever's currently in the input box, an incomplete token renders as raw Latin text until the full token is actually typed — verified directly by simulating a user typing "kyoto" one character at a time:

typed "k" { hiragana: 'k', katakana: 'k' } typed "ky" { hiragana: 'ky', katakana: 'ky' } typed "kyo" { hiragana: 'きょ', katakana: 'キョ' } // snaps in once the full yōon token completes typed "kyot" { hiragana: 'きょt', katakana: 'キョt' } typed "kyoto" { hiragana: 'きょと', katakana: 'キョト' }

This isn't a bug — it's the expected, real behavior of a tokenizer with no lookahead into characters the user hasn't typed yet, and it's the exact same real finding the Astro sibling course made against its own version of this converter, now independently re-confirmed here.

The Server-Comparison Box: A Deliberately Real Second Box

A second box calls the real /api/convert route from Chapter 4 instead of convert() directly. Built naively — firing a fetch() on every keystroke with no guard at all — it inherits two genuine real risks that the client-side box, with no network calls at all, never has to worry about.

Risk 1: A Real, Reproduced Out-of-Order Response

A real HTTP request doesn't always finish in the order it was sent — network timing genuinely varies per request. Simulating exactly that with two real, deliberately staggered async calls (an earlier request that happens to be slow, and a later one that happens to be fast) reproduces a real race condition:

// user types "s" (fires a slow request), then "su" 15ms later (fires a fast one) naiveFetch('s-result', 40).then(r => display(r)); // resolves LAST, after 40ms // ...15ms later... naiveFetch('su-result', 5).then(r => display(r)); // resolves FIRST, after 5ms // real, observed order: "su-result" applied first, then "s-result" overwrites it // FINAL displayed value: 's-result' — wrong. The user's actual last input was "su".
A Genuine Bug, Not a Hypothetical One
The user's real final input was "su" — the later request. But because its own response happened to arrive faster than the earlier, slower request for just "s," a naive fetch(...).then(setResult) handler applies whichever response lands last on the wire, not whichever request was fired last. Given Chapter 4's own real measured ~14ms round trip, and a real typist easily producing keystrokes closer together than that, this isn't an edge case — it's a genuine, likely outcome for the server-comparison box as written so far.

The Fix: A Latest-Request-Wins Guard

A simple incrementing request id, held in a useRef so it survives across renders without triggering one itself, lets a response check whether it's still the most recent request before it's allowed to update the displayed result:

// src/app/components/ServerConverter.tsx 'use client'; import { useState, useRef, useEffect } from 'react'; export default function ServerConverter() { const [input, setInput] = useState(''); const [result, setResult] = useState({ hiragana: '', katakana: '' }); const latestRequestId = useRef(0); useEffect(() => { if (!input) { setResult({ hiragana: '', katakana: '' }); return; } const timer = setTimeout(async () => { const thisRequestId = ++latestRequestId.current; const res = await fetch('/api/convert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ romaji: input }), }); const data = await res.json(); // only apply this response if no newer request has fired since if (thisRequestId === latestRequestId.current) { setResult(data); } }, 300); // genuinely useful here — Chapter 4's own real ~14ms cost is worth batching return () => clearTimeout(timer); }, [input]); return ( <div> <h3>Server-Side (via /api/convert)</h3> <input value={input} onChange={(e) => setInput(e.target.value)} /> <p>Hiragana: {result.hiragana}</p> <p>Katakana: {result.katakana}</p> </div> ); }

Re-running the exact same staggered-timing scenario against the guarded version:

applied (still latest): su-result discarded (stale): s-result FINAL displayed value: su-result // correct
A Real, Direct Payoff of Chapter 4's Own Measurement
The 300ms debounce isn't an arbitrary UX choice — it's a genuine consequence of Chapter 4's own measured ~14ms-per-request cost. Firing a real network request on every keystroke would mean a fast typist could easily trigger dozens of real HTTP round trips for a single word, each one paying that same real, measured cost — batching them behind a short delay is a direct, honest answer to a real number this course already measured, not a guess.

Hands-On Exercises

Exercise 1

Simulate real, live typing of "sushi" one character at a time against convert(), exactly as this chapter did for "kyoto." Predict the output at each step first, then verify with real code.

📄 View solution
Exercise 2

Reproduce this chapter's own race condition with real code, using three staggered requests instead of two (an early slow one, a middle-timed fast one, and a late medium-speed one). Verify the latest-request-wins guard still produces the correct final result.

📄 View solution
Exercise 3

Explain, in your own words, why the client-side box needs no debounce and no request-id guard at all, while the server-comparison box genuinely needs both — tracing each requirement back to a specific real finding from Chapter 4.

📄 View solution

Chapter 5 Quick Reference

  • Client-side box — "use client", useState, convert() called directly in render, no debounce needed (Chapter 4's own sub-microsecond finding)
  • Real, verified live-typing behavior — an incomplete token renders as raw Latin text until it fully completes
  • Server-comparison box — a genuine, reproduced out-of-order response race condition, fixed with a useRef-based latest-request-id guard
  • A real, justified 300ms debounce — a direct, honest consequence of Chapter 4's own measured ~14ms-per-request cost, not an arbitrary UX choice