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:

// src/app/convert.service.ts — a real Subject absorbs every raw call import { Subject } from 'rxjs'; import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; private readonly romajiInput$ = new Subject<string>(); constructor() { this.romajiInput$ .pipe( debounceTime(300), distinctUntilChanged(), switchMap((romaji) => this.http.post<ConvertResponse>('http://localhost:4000/api/convert', { romaji })) ) .subscribe((result) => { this.hiraganaResult.set(result.hiragana); this.katakanaResult.set(result.katakana); }); } convertText(romaji: string): void { this.romajiInput$.next(romaji); }
RomajiInputComponent's onInput() Is Identical to Chapter 4, Character for Character
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:

// typing "s", "su", "sus", "sush", "sushi" -- 10ms apart // then resending "sushi" (identical), then sending "sushi2" (new) emitted: sushi // the four earlier keystrokes never reached the network at all // (resending "sushi" produces no second emission -- distinctUntilChanged filters it) emitted: sushi2
Real, Run Confirmation
Five real keystrokes 10ms apart collapsed into exactly one real network request, for the final settled value only. An identical repeat of that same value produced no second request at all. Both are genuine, verified RxJS operator behavior — not just how 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:

// mergeMap instead of switchMap -- both requests run concurrently input$.next('su'); // fires at t=0, resolves at t=200 setTimeout(() => input$.next('sushi'), 50); // fires at t=50, resolves at t=100 // Real output, in the order it actually printed: applied result for "sushi" -> [converted:sushi] // arrives first (t≈100) applied result for "su" -> [converted:su] // arrives SECOND (t≈200) -- and overwrites it
A Real, Reproduced Stale-Result Bug
The user's actual final input was "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:

// Real output -- only one line, ever: applied result for "sushi" -> [converted:sushi]

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:

in$.next('sushi'); // a normal, successful conversion in$.next(''); // the user clears the input -- triggers Chapter 5's own 400 in$.next('sushi'); // the user starts typing again // Real output: next: sushi -> [converted:sushi] error (subscription now DEAD): Http failure response ... 400 Bad Request // -- the second "sushi" produces NO output at all. Ever again.
Confirmed: an Unhandled Error Inside switchMap Ends the Entire Outer Subscription
Once 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:

// Same two calls, error now caught INSIDE the switchMap projector: switchMap((romaji) => this.http.post<ConvertResponse>(url, { romaji }).pipe( catchError((err) => { /* handled here, doesn't reach the outer subscribe */ return EMPTY; }) ) ) // Real output: next: sushi -> [converted:sushi] caught inline: Http failure response ... 400 Bad Request -- pipeline stays alive next: sushi -> [converted:sushi] // the second "sushi" now goes through correctly
Verified: catchError() Inside switchMap Keeps the Pipeline Alive
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:

// src/app/convert.service.ts — the real, final Chapter 6 version import { Injectable, inject, signal, computed } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Subject, EMPTY } from 'rxjs'; import { debounceTime, distinctUntilChanged, switchMap, catchError } from 'rxjs/operators'; interface ConvertResponse { hiragana: string; katakana: string; } @Injectable({ providedIn: 'root' }) export class ConvertService { private http = inject(HttpClient); private readonly hiraganaResult = signal(''); private readonly katakanaResult = signal(''); private readonly loading = signal(false); private readonly errorMessage = signal<string | null>(null); readonly hiragana = this.hiraganaResult.asReadonly(); readonly katakana = this.katakanaResult.asReadonly(); readonly hasResult = computed(() => this.hiraganaResult().length > 0); readonly isLoading = this.loading.asReadonly(); readonly error = this.errorMessage.asReadonly(); private readonly romajiInput$ = new Subject<string>(); constructor() { this.romajiInput$ .pipe( debounceTime(300), distinctUntilChanged(), switchMap((romaji) => this.http.post<ConvertResponse>('http://localhost:4000/api/convert', { romaji }).pipe( catchError((err: HttpErrorResponse) => { this.loading.set(false); if (err.status === 400) { // The cleared-input case: expected, not a real failure. this.hiraganaResult.set(''); this.katakanaResult.set(''); } else { this.errorMessage.set("Couldn't reach the conversion service — try again."); } return EMPTY; }) ) ) ) .subscribe((result) => { this.loading.set(false); this.hiraganaResult.set(result.hiragana); this.katakanaResult.set(result.katakana); }); } convertText(romaji: string): void { this.loading.set(true); this.errorMessage.set(null); this.romajiInput$.next(romaji); } }
A Deliberate Design Choice, Named Honestly
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.
Why This Subscription Never Needs Unsubscribing
A component-scoped subscription needs real teardown — an 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:

<!-- src/app/romaji-input/romaji-input.html --> <div class="input-wrap"> <label for="romajiField">Type romaji</label> <input #romajiField id="romajiField" type="text" placeholder="e.g. konnichiwa" (input)="onInput(romajiField.value)" /> </div>
/* src/app/romaji-input/romaji-input.css */ .input-wrap { margin-bottom: 1.5rem; } label { display: block; font-size: 0.85rem; color: #8b949e; margin-bottom: 0.4rem; } input { width: 100%; padding: 0.7rem 0.9rem; font-size: 1.1rem; background: #161b22; border: 1px solid #30363d; border-radius: 8px; color: #e6e6e6; } input:focus { outline: none; border-color: #dd0031; }

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:

// src/app/kana-output/kana-output.ts import { Component, inject } from '@angular/core'; import { ConvertService } from '../convert.service'; @Component({ selector: 'app-kana-output', templateUrl: './kana-output.html', styleUrl: './kana-output.css', }) export class KanaOutputComponent { private convertService = inject(ConvertService); hiragana = this.convertService.hiragana; katakana = this.convertService.katakana; hasResult = this.convertService.hasResult; isLoading = this.convertService.isLoading; error = this.convertService.error; }
<!-- src/app/kana-output/kana-output.html --> @if (isLoading()) { <p class="status loading">Converting…</p> } @else if (error()) { <p class="status error">{{ error() }}</p> } @else if (hasResult()) { <div class="output"> <div class="kana-block"> <span class="kana-label">Hiragana</span> <span class="kana-text">{{ hiragana() }}</span> </div> <div class="kana-block"> <span class="kana-label">Katakana</span> <span class="kana-text">{{ katakana() }}</span> </div> </div> } @else { <p class="placeholder">Type something above to see the conversion.</p> }
/* src/app/kana-output/kana-output.css */ .status { font-size: 0.9rem; margin: 0; } .status.loading { color: #8b949e; } .status.error { color: #f2717d; } .placeholder { color: #6e7681; font-size: 0.9rem; } .output { display: flex; flex-direction: column; gap: 0.75rem; } .kana-block { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 0.8rem 1rem; } .kana-label { display: block; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: #8b949e; margin-bottom: 0.3rem; } .kana-text { font-size: 1.4rem; color: #e6e6e6; }

And a light pass over the page shell itself, matching Angular's own brand accent already used throughout this course's own banners:

<!-- src/app/app.html --> <div class="app-shell"> <h1>Romaji to Kana Converter</h1> <app-romaji-input /> <app-kana-output /> </div>
/* src/app/app.css */ .app-shell { max-width: 480px; margin: 3rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; color: #e6e6e6; } h1 { font-size: 1.5rem; color: #dd0031; margin-bottom: 1.5rem; }

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

Exercise 1

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 solution
Exercise 2

Using 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 solution
Exercise 3

Build 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 solution

Chapter 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