Components, forms, server calls: all the pieces are there. But eventually comes the question marking the move from demos to real apps: how do two components share information?
Imagine one component lets you select a game, and elsewhere in the interface another must display its details. They are not parent and child; they may live in completely different branches of the app. How do they communicate?
The answer is what I hinted at last chapter: with a service. In chapter 7, services handled HTTP calls; today we explore their second job, perhaps the most important: holding application state.
🧩 The principle: state lives in one place
A quick recap: a service with providedIn: 'root' is a singleton. Angular creates one instance for the whole app and supplies it through dependency injection to anyone requesting it with inject().
That is the key: if two components inject the same service, they look at the same object. Put state there in a signal, and sharing follows naturally: writers update the signal, readers update automatically. This is the single source of truth pattern: data lives in one place, and everyone else observes it.
🛠️ Step 1: the service holding state
Let us build the selected-game example. The first version is as simple as possible:
1// game-selection.service.ts
2import { Injectable, signal } from '@angular/core';
3
4@Injectable({ providedIn: 'root' })
5export class GameSelectionService {
6 selectedGame = signal<string | null>(null);
7
8 select(game: string) {
9 this.selectedGame.set(game);
10 }
11}It already works. But one refinement always pays off in real projects: as written, anyone injecting the service can call selectedGame.set(...) from anywhere in the app, and after six months you no longer know who changes what. Better to expose a read-only signal and route writes through service methods:
1import { Injectable, signal } from '@angular/core';
2
3@Injectable({ providedIn: 'root' })
4export class GameSelectionService {
5 private _selectedGame = signal<string | null>(null);
6
7 // the outside world can read...
8 readonly selectedGame = this._selectedGame.asReadonly();
9
10 // ...but writes go through here
11 select(game: string) {
12 this._selectedGame.set(game);
13 }
14
15 clear() {
16 this._selectedGame.set(null);
17 }
18}The service is now the only place the selection can change. If something does not add up, you know exactly where to look.
🧪 Step 2: the component that writes
The selector injects the service and calls its method:
1// game-selector.component.ts
2import { Component, inject } from '@angular/core';
3import { GameSelectionService } from './game-selection.service';
4
5@Component({
6 selector: 'app-game-selector',
7 template: `
8 <h3>Select a game:</h3>
9 <button (click)="select('Zelda')">Zelda</button>
10 <button (click)="select('Metaphor')">Metaphor</button>
11 `
12})
13export class GameSelectorComponent {
14 private gameService = inject(GameSelectionService);
15
16 select(game: string) {
17 this.gameService.select(game);
18 }
19}👁️ Step 3: the component that reads
The detail component injects the same service and simply reads the signal:
1// game-details.component.ts
2import { Component, inject } from '@angular/core';
3import { GameSelectionService } from './game-selection.service';
4
5@Component({
6 selector: 'app-game-details',
7 template: `
8 <h3>Gioco selezionato:</h3>
9 @if (selectedGame()) {
10 <p>{{ selectedGame() }}</p>
11 } @else {
12 <p>No game selected</p>
13 }
14 `
15})
16export class GameDetailsComponent {
17 selectedGame = inject(GameSelectionService).selectedGame;
18}Done. Click "Zelda" in the first component, and the second updates instantly without either knowing the other: no direct reference, no events propagated up the tree. The service is their meeting point; signals do the rest.
What about derived state?
Chapter 5's computed signals also belong in services. If the detail component needs to know whether a selection exists, there is no need for a second state to keep synchronized:
readonly hasSelection = computed(() => this._selectedGame() !== null);The same rule as always: derive anything derivable; do not duplicate it.
Parent and child, or distant relatives?
A service is not the only way components communicate, and you should choose based on their distance:
For a direct parent and child, inputs (already seen with
input()in chapter 4) and outputs for upward events are enough. A service is useful when components are far apart in the tree, or state must survive navigation: change pages, return, and the selection is still there because the singleton lives as long as the app.
📌 When to use a service for state
- Multiple components need to read or write the same value.
- State must survive navigation between pages.
- You want to separate logic from presentation and test it without mounting components.
An honest point: for the vast majority of apps, services + signals are all you need. State management libraries such as NgRx make sense for large projects, sizeable teams and specific requirements (undo/redo, tracing every change). Do not start there: start with a service, and let the project convince you to change, rather than fashion.
Where we are now
The state picture is complete: signals in components for local state, services with signals for shared state, asReadonly() to protect writes and computed for derived values. With this pattern, a modern Angular app's structure no longer holds architectural mysteries.
In the next chapter, we close the basic module with two finishing tools: interceptors, for handling all HTTP calls in one place, and pipes, for presenting data properly.
