State architecture with signals
Key takeaway — An
@Injectableservice that encapsulates private signals and exposes only reads (asReadonly,computed) constitutes a simple, testable reactive store with no external dependency.linkedSignalhandles local state that must reset based on a source.
You know how to manipulate signals inside a component. Let's now see how to structure an application's state: sharing state between components, keeping it clean, and knowing the advanced tools (linkedSignal) before the production best practices.
The "signal-based state service" pattern
The simplest way to share reactive state is a providedIn: 'root' service. You apply the principle of encapsulation to it: the mutable state is private, you expose only read accesses and methods that describe the transitions.
import { Injectable, signal, computed, inject, ChangeDetectionStrategy, Component } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CompteurStore {
// 1. Private state, only the store can write to it.
private readonly _valeur = signal(0);
// 2. Public reads: read-only signal + derived values.
readonly valeur = this._valeur.asReadonly();
readonly estPositif = computed(() => this._valeur() > 0);
// 3. Public API: intentions, not raw setters.
incrementer(): void {
this._valeur.update((n) => n + 1);
}
decrementer(): void {
this._valeur.update((n) => n - 1);
}
reinitialiser(): void {
this._valeur.set(0);
}
}
Any component injects the store and reads its signals. All consumers stay synchronized automatically:
@Component({
selector: 'app-affichage',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>
Shared value: <strong>{{ store.valeur() }}</strong>
@if (store.estPositif()) { <span>✅</span> }
</p>
`,
})
export class AffichageComponent {
readonly store = inject(CompteurStore);
}
@Component({
selector: 'app-root',
standalone: true,
imports: [AffichageComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Shared state between components</h1>
<app-affichage />
<button (click)="store.decrementer()">−</button>
<button (click)="store.incrementer()">+</button>
`,
})
export class AppComponent {
readonly store = inject(CompteurStore);
}
Try it live
The button and the display are two distinct components linked by the same store:
state-service
This pattern covers a large part of the needs. For larger applications, libraries like NgRx SignalStore formalize the same idea (state + computed + methods) with extra tooling (entities, effects, devtools).
Modeling realistic state
A production store often groups several signals and exposes business derivations:
interface Filtre {
recherche: string;
seulementActifs: boolean;
}
@Injectable({ providedIn: 'root' })
export class ProduitsStore {
private readonly _produits = signal<Produit[]>([]);
private readonly _filtre = signal<Filtre>({ recherche: '', seulementActifs: false });
readonly produits = this._produits.asReadonly();
readonly filtre = this._filtre.asReadonly();
// Derived view: the filtered list, recomputed only when needed.
readonly produitsFiltres = computed(() => {
const { recherche, seulementActifs } = this._filtre();
const terme = recherche.trim().toLowerCase();
return this._produits()
.filter((p) => !seulementActifs || p.actif)
.filter((p) => p.nom.toLowerCase().includes(terme));
});
readonly nombre = computed(() => this.produitsFiltres().length);
definirRecherche(recherche: string): void {
this._filtre.update((f) => ({ ...f, recherche }));
}
basculerActifs(): void {
this._filtre.update((f) => ({ ...f, seulementActifs: !f.seulementActifs }));
}
}
The interface only consumes produitsFiltres() and nombre(): all the filtering logic is centralized, testable and reactive.
linkedSignal: local state linked to a source
Sometimes you need a state modifiable by the user that must yet reset when a source datum changes. Example: a list of options and the selected option — if the list changes, the selection must return to the first element.
A plain signal is not enough (it ignores the source); a computed is not either (it is not modifiable). The answer is linkedSignal (Angular 19):
import { Component, signal, linkedSignal, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Pick a size</h1>
@for (option of options(); track option) {
<button
(click)="choix.set(option)"
[style.fontWeight]="choix() === option ? '700' : '400'"
>
{{ option }}
</button>
}
<p>Selection: <strong>{{ choix() }}</strong></p>
<button (click)="remplacerOptions()">Replace the option list</button>
`,
})
export class AppComponent {
readonly options = signal(['Petit', 'Moyen', 'Grand']);
// Modifiable like a signal, BUT reset as soon as options() changes.
readonly choix = linkedSignal(() => this.options()[0]);
remplacerOptions(): void {
this.options.set(['XS', 'S', 'M', 'L']); // → choix goes back to 'XS'
}
}
Try it live
Select a size, then replace the list: the selection resets.
linked-signal
Testing a signal-based store
Testing becomes trivial: no RxJS marbles, you read the signals directly.
import { TestBed } from '@angular/core/testing';
describe('CompteurStore', () => {
it('increments the value and reflects estPositif', () => {
const store = TestBed.inject(CompteurStore);
expect(store.valeur()).toBe(0);
expect(store.estPositif()).toBe(false);
store.incrementer();
expect(store.valeur()).toBe(1);
expect(store.estPositif()).toBe(true);
});
});
Production best practices
- Encapsulate: private signal
_x, exposure viaasReadonly()/computed. Never a publicWritableSignal. - Expose intentions, not setters:
ajouterAuPanier(produit)rather thanpanier.set(...)on the component side. - Derive with
computed, do not "stack" effects to synchronize signals with each other. - Immutability: always a new reference for arrays and objects.
OnPush+ signals in all components.linkedSignalfor local state dependent on a source;resource()for asynchronous data (chapter 7).- For very large applications, evaluate NgRx SignalStore — but the homemade service is enough in most cases.
Key points
- A signal-based service (private +
asReadonly/computed+ methods) is a simple, testable reactive store. - Centralize business logic in
computed. linkedSignalhandles local state that resets based on a source.- Encapsulation, immutability,
OnPushand derivation are the pillars of a sound architecture.
You have gone through Angular's entire reactive model: from the three primitives to a production architecture. The final quiz validates the whole thing.