Angular Services & Components: Structuring the Conversion Tool

Romaji to Kana Converter: Angular & Express

Chapter 4 · Angular Services & Components: Structuring the Conversion Tool

convert() is finished — Chapters 2 and 3 built a real, tested, framework-agnostic function that correctly handles the full gojūon grid, yōon, long vowels, sokuon, and the particle exceptions. Everything Angular-facing so far, though, is still just Chapter 1's placeholder: one component pinging one endpoint. This chapter is where the app actually gets structured — a real service holding the tool's own state, and two small components that read and write it, with no HTTP call anywhere yet. That's deliberate: Chapter 5 is where the conversion logic moves behind the real Express API this course exists to build. This chapter's own job is making sure that move, when it happens, costs nothing.

Why AppComponent Can't Keep Doing Everything

Chapter 1's AppComponent injected ConvertService directly, called ping() in ngOnInit, and rendered the result inline — reasonable for confirming two servers can talk to each other, but it doesn't scale to a real conversion tool. A text input, a live output panel, and (from Chapter 6 onward) probably a conversion history all need to react to the same underlying state. Cramming that into one component means every new feature touches the same file, and nothing in it can be reused, tested, or reasoned about on its own.

The fix is the same one this site's own React-based courses reach for with a shared hook or context — in Angular, a singleton service holding the real state, injected wherever it's needed.

Extending ConvertService With Real Conversion State

Chapter 1's own convert.service.ts already exists with one method, ping(). Rather than starting a second service, this chapter grows it — the same file gains a genuine convert-and-remember responsibility, built on Angular's own signal primitives:

// src/app/convert.service.ts — extending Chapter 1's placeholder import { Injectable, inject, signal, computed } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { convert } from './conversion-engine'; @Injectable({ providedIn: 'root' }) export class ConvertService { private http = inject(HttpClient); // Writable, private — nothing outside this service can set these directly. private readonly hiraganaResult = signal(''); private readonly katakanaResult = signal(''); // Read-only views, safe to expose to any component that injects this service. readonly hiragana = this.hiraganaResult.asReadonly(); readonly katakana = this.katakanaResult.asReadonly(); readonly hasResult = computed(() => this.hiraganaResult().length > 0); ping() { return this.http.get('http://localhost:4000/api/ping'); } convertText(romaji: string): void { // For now, this calls Chapter 3's engine directly, in-process. Chapter 5 // replaces the two lines below with a real HTTP call — see the finding-box. const result = convert(romaji); this.hiraganaResult.set(result.hiragana); this.katakanaResult.set(result.katakana); } }
Why the Setters Are Private, but the Signals Are Public
hiraganaResult and katakanaResult are declared private — a component can read the current hiragana value through hiragana(), but it can never call hiraganaResult.set(...) itself, since that field isn't visible outside the class at all. asReadonly() gives back a genuine read-only signal (calling .set() on it is a compile-time error), so the only way any conversion result ever changes is by calling convertText() — exactly one path in, exactly one path out.
The Actual Payoff, Named Up Front
convertText(romaji: string): void doesn't return the conversion result at all — it just updates the service's own signals, and every interested component reads those signals directly. That shape is what makes Chapter 5 free: when convertText()'s own two-line body is replaced with a real this.http.post(...).subscribe(...) call that sets the same two signals from the response instead of from a direct function call, nothing outside this one method changes — not its name, not its parameters, not a single line in either component built below.

Building the Input Component

Generated the normal way, in its own folder, matching the 2025 file-naming convention from Chapter 1 — romaji-input.ts, not romaji-input.component.ts:

ng generate component romaji-input
// src/app/romaji-input/romaji-input.ts import { Component, inject } from '@angular/core'; import { ConvertService } from '../convert.service'; @Component({ selector: 'app-romaji-input', templateUrl: './romaji-input.html', styleUrl: './romaji-input.css', }) export class RomajiInputComponent { private convertService = inject(ConvertService); onInput(value: string) { this.convertService.convertText(value); } }
<!-- src/app/romaji-input/romaji-input.html --> <input #romajiField type="text" placeholder="Type romaji, e.g. konnichiwa" (input)="onInput(romajiField.value)" />

#romajiField is a template reference variable — it names the raw DOM element so romajiField.value can be read directly inside the event binding, with no separate Angular-side signal needed just to mirror what the input box already holds. Every keystroke calls onInput(), which calls convertText(), which updates the service's own signals — this component never touches hiragana or katakana at all; it only ever writes.

Building the Output Component

ng generate component kana-output
// 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); // Signals themselves are assigned, not called — reading them happens in the // template, where hiragana() and katakana() are invoked on every change. hiragana = this.convertService.hiragana; katakana = this.convertService.katakana; hasResult = this.convertService.hasResult; }
<!-- src/app/kana-output/kana-output.html --> @if (hasResult()) { <div class="output"> <p>Hiragana: {{ hiragana() }}</p> <p>Katakana: {{ katakana() }}</p> </div> } @else { <p class="placeholder">Type something above to see the conversion.</p> }
@if/@else Needs No Import
Older Angular templates reached for *ngIf, which only works once CommonModule is imported. The @if/@else block syntax used here is current Angular's own built-in control flow — compiled directly by the template compiler, with nothing to import at all. It's exactly the kind of small, real "verified against current documentation" detail this course keeps flagging, since a tutorial written even a couple of years ago would show the older directive instead.

Composing Everything in app.ts

AppComponent's own job shrinks to exactly one thing: import the two new components and lay them out. Chapter 1's ngOnInit ping check already did its job proving the two halves of this stack can talk to each other — it's removed now, since there's nothing left for AppComponent itself to compute or hold:

// src/app/app.ts import { Component } from '@angular/core'; import { RomajiInputComponent } from './romaji-input/romaji-input'; import { KanaOutputComponent } from './kana-output/kana-output'; @Component({ selector: 'app-root', templateUrl: './app.html', styleUrl: './app.css', imports: [RomajiInputComponent, KanaOutputComponent], }) export class AppComponent {}
<!-- src/app/app.html --> <h1>Romaji to Kana Converter</h1> <app-romaji-input /> <app-kana-output />

Running ng serve and typing into the input box should update the output panel live, on every keystroke — no styling yet, no debouncing, nothing polished; Chapter 6 is where the actual UI gets built. This chapter's own goal was structural, and it's already testable: type "sushi" and confirm すし/スシ appear.

Two Siblings, Zero Direct Wiring

RomajiInputComponent and KanaOutputComponent sit next to each other in app.html, but neither one knows the other exists. There's no @Input() passing a value down from a parent, and no @Output() emitting an event back up — both components simply inject the same singleton ConvertService and talk to it independently.

PatternWhen It Fits
Shared service (used here)State genuinely belongs to the whole feature, not to one component — any number of components can read or write it without ever knowing about each other
Parent-child input()/output()/model()A component is truly nested inside another and only that one relationship needs to pass data down or events up — current Angular's modern replacement for the older @Input()/@Output() decorators

This app has no genuine parent-child relationship between the two pieces that need to communicate — an input box and an output panel are siblings, not one nested inside the other — so the shared-service pattern is the more direct fit. The input()/output()/model() functions are worth knowing regardless; a future feature that genuinely does nest one component inside another (a settings panel embedded in the output component, say) would reach for those instead.

What Chapter 5 Changes, and What It Won't

Chapter 5 gives Express a real /api/convert route, replacing today's /api/ping placeholder. ConvertService.convertText() will change from calling convert() directly to calling this.http.post(...) and updating the same two signals from inside a .subscribe() callback instead of on the very next line. That's the entire change. Neither RomajiInputComponent nor KanaOutputComponent will be touched at all — they were never written against "a function call" or "an HTTP request," only against "whatever ConvertService exposes," and that public shape isn't moving.

Hands-On Exercises

Exercise 1

Extend ConvertService exactly as this chapter describes (the private writable signals, the public asReadonly() views, the computed hasResult, and convertText()). Without building either component yet, call service.convertText('konnichiwa') directly and confirm service.hiragana() and service.katakana() return the correct real strings afterward, and that service.hasResult() is false before the call and true after it.

📄 View solution
Exercise 2

Build RomajiInputComponent and KanaOutputComponent exactly as this chapter describes, wire them into app.ts and app.html in place of Chapter 1's ping check, and confirm typing "sushi" into the input field correctly displays すし and スシ in the output panel — including confirming the @else placeholder text is what shows before anything has been typed.

📄 View solution
Exercise 3

In your own words, explain why convertText() returning void and updating signals internally is what lets Chapter 5 swap its own implementation for a real HTTP call with zero changes to either component. Then describe specifically what WOULD have had to change in both RomajiInputComponent and KanaOutputComponent if convertText() had instead directly returned a { hiragana, katakana } object for the caller to handle itself.

📄 View solution

Chapter 4 Quick Reference

  • ConvertService grows up — Chapter 1's ping-only placeholder now holds real conversion state via private writable signals, public asReadonly() views, and a computed hasResult
  • convertText(romaji): void — updates the service's own signals rather than returning a value; currently calls Chapter 3's engine directly, in-process
  • RomajiInputComponent — a template reference variable (#romajiField) reads the raw input value; every keystroke calls convertText()
  • KanaOutputComponent — reads hiragana/katakana/hasResult straight off the service; renders with @if/@else, current Angular's built-in control flow (no CommonModule import needed)
  • Shared service over parent-child wiring — the two components are siblings with no direct relationship, so they communicate through the injected service rather than input()/output()/model()
  • The real payoff — Chapter 5 can replace convertText()'s own two-line body with a real HTTP call and change nothing else in the app
  • Next chapter: The Express API — the real conversion route this course exists to build