State architecture with signals

Key takeaway — An @Injectable service that encapsulates private signals and exposes only reads (asReadonly, computed) constitutes a simple, testable reactive store with no external dependency. linkedSignal handles 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 via asReadonly() / computed. Never a public WritableSignal.
  • Expose intentions, not setters: ajouterAuPanier(produit) rather than panier.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.
  • linkedSignal for 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.
  • linkedSignal handles local state that resets based on a source.
  • Encapsulation, immutability, OnPush and 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.

We use Microsoft Clarity to understand how the site is used and improve it. By continuing to browse, you accept it. You can disable it at any time.