← Back to the seriesThe Galactic Guide to Angular · 8 / 10

Shared state: services as a single source of truth

Shared state: services as a single source of truth

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:

TYPESCRIPT
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:

TYPESCRIPT
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:

TYPESCRIPT
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:

TYPESCRIPT
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:

TYPESCRIPT
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.

Fullstack developer in Milan. Writes about Angular, the JavaScript ecosystem and AI.