Building the Input/Output UI
Romaji to Kana Converter: Angular & Express
Chapter 6 · Building the Input/Output UI
Chapter 5 closed on three things left deliberately undone: no debounce (every keystroke fires a real network request), no visible error state (a failed request just logs to the console), and no actual styling at all. This chapter fixes all three — and along the way, actually running the naive versions of two of them surfaces two genuine, reproducible RxJS bugs, the same "build it, watch it break for a real reason, then fix it" pattern this course has used since Chapter 3.
Debouncing Requests Without Touching Either Component
The debounce logic could live inside RomajiInputComponent — buffer keystrokes there, only call
convertService.convertText() once things settle. But convertText(romaji): void is
already the one seam this whole architecture has been built around since Chapter 4. Moving the debounce
inside the service instead means the public contract — "call convertText() as often as
you like" — never has to change, and RomajiInputComponent can go on calling it exactly the way
it always has, on every single keystroke:
onInput(value: string) { this.convertService.convertText(value); } — the exact same line, still
firing on every keystroke, with no knowledge that a debounce now sits between it and the real network call.
That's the strongest version yet of the promise Chapters 4 and 5 both made: the component was built against
what the service does, never how often or how it does it.
Confirmed by actually running the pipeline against a burst of fast keystrokes (10ms apart, well under the 300ms debounce window), then a repeat of the settled value, then a genuinely new one:
debounceTime/distinctUntilChanged are
documented to work, but how they were actually observed to work here.
A Real Race Condition: Why switchMap, Not mergeMap
switchMap wasn't picked at random above. Real network responses don't always arrive in the same
order the requests were sent — a short, simple request can genuinely take longer to come back than a later,
longer one, purely from server load or network jitter. Swapping switchMap for
mergeMap (which runs every inner request concurrently, applying whichever result arrives first)
reproduces that failure directly, using a deliberately extreme but entirely realistic timing: a request for
"su" that happens to take 200ms, followed 50ms later by a request for "sushi" that
only takes 50ms:
"sushi", and its correct result briefly appeared — then got
silently overwritten by the stale result for "su", an earlier keystroke the user has already
typed past. This is genuine, run output, not a hypothetical: two ordinary Subject.next() calls,
50ms apart, with response times realistic enough that either one could plausibly win in production.
The exact same two calls, through switchMap instead:
switchMap unsubscribes from the in-flight "su" request the instant
"sushi" arrives — the slower response for "su" is never even waited for, let alone
applied. There's structurally no way for a stale result to win, because the only request left alive is
always the most recent one.
A Second Real Bug: One Bad Response Can Kill the Whole Pipeline
Chapter 5's own /api/convert route deliberately returns a real 400 whenever romaji
is empty. Clearing the input field is about as ordinary a user action as this app has — and it triggers that
exact 400 every single time. Run the debounced pipeline exactly as built above against that real scenario:
HttpClient's Observable errors on that 400 response, the error propagates straight through
switchMap and terminates the whole piped chain — including
debounceTime/distinctUntilChanged upstream of it. This isn't specific to a 400; any
genuine network failure does the exact same thing. Since ConvertService is a root singleton that
lives for the app's entire lifetime, this subscription is never rebuilt — the very next real user, on their
very next real keystroke, would find the converter permanently, silently broken until the page is reloaded.
The fix is catchError, placed inside switchMap's own projection function
so it only ever touches that one inner request, never the outer pipeline:
EMPTY completes just the one inner Observable with no value at all — the outer subscription
that's actually listening for future keystrokes is never touched, and the very next real input goes through
exactly as if nothing had gone wrong.
Giving ConvertService Real Loading & Error State
With the pipeline now provably survivable, the service gets two more signals — one to show a real loading indicator, one to show a real error message — and the empty-input 400 gets its own honest handling, since a cleared input box isn't a genuine failure and shouldn't look like one:
RomajiInputComponent could just as easily skip calling convertText() at all when
the field is empty, avoiding the round trip entirely. This chapter handles it at the service layer instead —
partly to keep the component untouched, but mainly because catchError has to exist for genuine
network failures regardless; distinguishing the 400 case there costs one if and covers both
problems with the one mechanism already doing the real work.
ngOnDestroy, or the async
pipe handling it automatically — because the component itself can be destroyed while a subscription is still
live. ConvertService is a root singleton: it's constructed once and lives for the entire app's
lifetime, so a subscription set up in its own constructor never needs to be cleaned up at all.
Building the Real Input Component
A label, a focus state, and real spacing — onInput() itself is left exactly as it was:
Building the Real Output Component
Four states, in a real priority order — loading first, then a genuine error, then a real result, then the empty placeholder — reading straight off the two new signals alongside the ones from Chapter 4:
And a light pass over the page shell itself, matching Angular's own brand accent already used throughout this course's own banners:
Confirming the Full Loop, End to End
Running both servers and typing a whole word quickly now shows exactly one real request in the DevTools
Network tab, fired only once typing pauses — not one per keystroke, the honest cost Chapter 5 closed on.
Clearing the field shows the placeholder text reappear, with no error banner and no broken pipeline; typing
again afterward converts correctly, proving the 400-handling branch works exactly as verified above.
Stopping the Express server mid-session and typing something new shows the real
"Couldn't reach the conversion service" message — and restarting Express and typing again
recovers cleanly, without a page reload, proving catchError's own real payoff one more time.
Hands-On Exercises
Build the debounced romajiInput$ pipeline inside ConvertService exactly as this chapter describes (debounceTime, distinctUntilChanged, switchMap, no catchError yet). Reproduce the race condition by sending 'su' then, 50ms later, 'sushi' — first through mergeMap (confirm 'su' incorrectly overwrites 'sushi'), then through switchMap (confirm only 'sushi' is ever applied).
📄 View solutionUsing the real running Express server from Chapter 5, send 'sushi', then '' (triggering the real 400), then 'sushi' again through the switchMap pipeline with no catchError. Confirm the second 'sushi' produces no output at all. Then add catchError inside the switchMap projector exactly as this chapter describes, and confirm the second 'sushi' now correctly goes through.
📄 View solutionBuild the final ConvertService (with isLoading and error signals) and the styled RomajiInputComponent/KanaOutputComponent exactly as this chapter describes. Confirm, using the real app: (a) a loading state briefly appears while typing, (b) clearing the input shows the placeholder text with no error, and (c) stopping the Express server and typing shows the real error message, recovering correctly once the server is restarted.
📄 View solutionChapter 6 Quick Reference
- Debounce lives in the service — a Subject piped through debounceTime(300)/distinctUntilChanged/switchMap; RomajiInputComponent's onInput() is unchanged, character for character, from Chapter 4
- Real bug 1 — mergeMap runs concurrent requests and applies whichever resolves last, letting a slower, older response overwrite a faster, newer one; switchMap cancels the stale in-flight request instead, verified with a real out-of-order timing test
- Real bug 2 — an unhandled HTTP error inside switchMap (including Chapter 5's own real 400 for an empty input) kills the entire outer subscription permanently; catchError placed inside the switchMap projector, returning EMPTY, keeps the pipeline alive
- Real signals added — isLoading and error, both read-only, both driven from inside the same pipeline; a 400 is handled as an expected empty state, not shown as an error
- Real UI states, in priority order — loading, then error, then result, then placeholder
- Next chapter: Deployment