Construisez une couche de persistance Angular avec Signals et localStorage : sync auto, schémas typés, migrations de version et synchronisation multi-onglet.
Pourquoi cette combinaison gagne
Persister une partie de l'état d'une app Angular dans localStorage est un besoin universel : préférences utilisateur, panier d'achat, filtres, brouillons de formulaire, état du dark mode. Avant les Signals, le code typique ressemblait à ça :
// AVANT — service avec BehaviorSubject
@Injectable({ providedIn: 'root' })
export class ThemeService {
private theme$ = new BehaviorSubject<string>(
localStorage.getItem('theme') ?? 'light'
);
readonly theme = this.theme$.asObservable();
constructor() {
this.theme$.subscribe(t => localStorage.setItem('theme', t));
}
setTheme(t: string) { this.theme$.next(t); }
}
Avec les Signals, le code est plus direct, plus court, sans subscribe ni risque de fuite mémoire :
// APRÈS — service avec Signal + effect
@Injectable({ providedIn: 'root' })
export class ThemeService {
readonly theme = signal<string>(
localStorage.getItem('theme') ?? 'light'
);
constructor() {
// effect persiste automatiquement à chaque changement
effect(() => {
localStorage.setItem('theme', this.theme());
});
}
}
Bénéfices :
- Lecture synchrone naturelle dans le template :
{{ theme.theme() }} - Aucune souscription à gérer, aucun
OnDestroy - Compatible zoneless (les Signals sont natifs)
- Le pattern peut être abstrait dans un helper réutilisable
Première version : signal + effect()
Voici une implémentation complète et fiable en moins de 20 lignes, pour persister un objet de préférences utilisateur.
// preferences.service.ts
import { Injectable, signal, effect } from '@angular/core';
export interface UserPreferences {
theme: 'light' | 'dark';
language: 'fr' | 'en';
fontSize: number;
}
const STORAGE_KEY = 'app:preferences';
const DEFAULT_PREFS: UserPreferences = {
theme: 'light',
language: 'fr',
fontSize: 16
};
@Injectable({ providedIn: 'root' })
export class PreferencesService {
// Lecture initiale depuis localStorage avec fallback sur les défauts
readonly prefs = signal<UserPreferences>(this.loadFromStorage());
constructor() {
// À chaque changement du Signal, on persiste
effect(() => {
const value = this.prefs();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch (e) {
// QuotaExceededError : storage plein ou bloqué (Safari privé)
console.warn('Impossible de persister les préférences', e);
}
});
}
private loadFromStorage(): UserPreferences {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_PREFS };
const parsed = JSON.parse(raw);
// Merge avec les défauts pour gérer les nouveaux champs
return { ...DEFAULT_PREFS, ...parsed };
} catch {
// JSON corrompu : revenir aux défauts
return { ...DEFAULT_PREFS };
}
}
// API ergonomique — modifier un seul champ
update<K extends keyof UserPreferences>(key: K, value: UserPreferences[K]): void {
this.prefs.update(p => ({ ...p, [key]: value }));
}
reset() {
this.prefs.set({ ...DEFAULT_PREFS });
}
}
Utilisation dans un composant :
@Component({
template: `
<label>Thème :</label>
<select [value]="prefs.prefs().theme" (change)="onThemeChange($event)">
<option value="light">Clair</option>
<option value="dark">Sombre</option>
</select>
<p>Taille de police : {{ prefs.prefs().fontSize }}px</p>
`
})
export class SettingsComponent {
prefs = inject(PreferencesService);
onThemeChange(e: Event) {
const v = (e.target as HTMLSelectElement).value as 'light' | 'dark';
this.prefs.update('theme', v);
}
}
Un helper persistedSignal réutilisable
Plutôt que de répéter le pattern dans chaque service, abstrayons-le dans une fonction utilitaire.
// utils/persisted-signal.ts
import { effect, signal, untracked, WritableSignal } from '@angular/core';
interface PersistedOptions<T> {
/** Valeur si rien n'est stocké ou si le parsing échoue */
defaultValue: T;
/** Storage utilisé (par défaut localStorage) */
storage?: Storage;
/** Sérialisation custom (par défaut JSON.stringify) */
serialize?: (value: T) => string;
/** Désérialisation custom */
deserialize?: (raw: string) => T;
}
export function persistedSignal<T>(
key: string,
options: PersistedOptions<T>
): WritableSignal<T> {
const storage = options.storage ?? localStorage;
const serialize = options.serialize ?? JSON.stringify;
const deserialize = options.deserialize ?? JSON.parse;
// Lecture initiale
let initial: T;
try {
const raw = storage.getItem(key);
initial = raw !== null ? deserialize(raw) : options.defaultValue;
} catch {
initial = options.defaultValue;
}
const sig = signal<T>(initial);
// Effect qui persiste à chaque changement
effect(() => {
const value = sig();
try {
storage.setItem(key, serialize(value));
} catch (e) {
// Quota dépassé ou storage indisponible
console.warn(`persistedSignal[${key}] : impossible d'écrire`, e);
}
});
return sig;
}
Utilisation simplifiée à l'extrême :
@Injectable({ providedIn: 'root' })
export class CartService {
readonly items = persistedSignal<CartItem[]>('app:cart', {
defaultValue: []
});
readonly total = computed(() =>
this.items().reduce((s, i) => s + i.price * i.quantity, 0)
);
add(item: CartItem) {
this.items.update(list => [...list, item]);
}
clear() {
this.items.set([]);
}
}
items est persisté — il utilise un Signal classique.
Variante avec debounce : pour les Signals modifiés très fréquemment (input texte d'un brouillon), debouncer l'écriture évite de marteler le storage.
export function persistedSignalDebounced<T>(
key: string,
options: PersistedOptions<T> & { debounceMs?: number }
): WritableSignal<T> {
const sig = persistedSignal(key, options);
// ... wrap signal pour ajouter un debounce avec setTimeout
let timeout: ReturnType<typeof setTimeout> | null = null;
const wait = options.debounceMs ?? 300;
effect(() => {
const value = sig();
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
localStorage.setItem(key, JSON.stringify(value));
}, wait);
});
return sig;
}
Typage strict et JSON safety
JSON.parse retourne any. Sans validation, votre Signal typé peut contenir n'importe quoi à l'exécution si quelqu'un manipule le localStorage manuellement (ou si une vieille version a stocké un autre format).
Approche 1 — validation manuelle (légère, sans dépendance) :
function isValidPreferences(value: unknown): value is UserPreferences {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return (
(v.theme === 'light' || v.theme === 'dark') &&
(v.language === 'fr' || v.language === 'en') &&
typeof v.fontSize === 'number'
);
}
// Dans loadFromStorage
const parsed = JSON.parse(raw);
return isValidPreferences(parsed) ? parsed : DEFAULT_PREFS;
Approche 2 — Zod pour les schémas complexes (recommandé) :
import { z } from 'zod';
const PreferencesSchema = z.object({
theme: z.enum(['light', 'dark']),
language: z.enum(['fr', 'en']),
fontSize: z.number().min(10).max(32)
});
type UserPreferences = z.infer<typeof PreferencesSchema>;
function loadFromStorage(): UserPreferences {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_PREFS;
// safeParse retourne { success, data | error } sans throw
const result = PreferencesSchema.safeParse(JSON.parse(raw));
return result.success ? result.data : DEFAULT_PREFS;
} catch {
return DEFAULT_PREFS;
}
}
fontSize: number. Un utilisateur a stocké la v1 avec fontSize: "16px" (string). Sans validation, votre code crashe au premier accès. La validation force un fallback propre.
Synchroniser plusieurs onglets
Si l'utilisateur ouvre votre app dans deux onglets et change le thème dans le premier, le second doit suivre. Le navigateur fournit l'événement storage spécialement pour cela.
export function persistedSignalSynced<T>(
key: string,
options: PersistedOptions<T>
): WritableSignal<T> {
const sig = persistedSignal(key, options);
if (typeof window !== 'undefined') {
// Écouter les changements provenant d'autres onglets
window.addEventListener('storage', (e) => {
// Filtre : seulement notre clé, et seulement si une nouvelle valeur existe
if (e.key !== key || e.newValue === null) return;
try {
const parsed = JSON.parse(e.newValue);
// untracked pour éviter une boucle infinie avec l'effect d'écriture
untracked(() => sig.set(parsed));
} catch {
// valeur corrompue dans l'autre onglet — on ignore
}
});
}
return sig;
}
Points importants :
- L'événement
storagene se déclenche pas dans l'onglet qui écrit. C'est exactement le comportement souhaité. - Sans
untracked(), lesig.set()dans le listener pourrait retrigger l'effect d'écriture, ce qui réécrit la même valeur — pas dramatique mais inutile. - Le filtrage par
e.keyest crucial : tous les changements de localStorage déclenchent l'événement.
BroadcastChannel + localStorage. C'est un pattern plus robuste pour les apps multi-modules.
Versionnement et migrations
À mesure que votre app évolue, le format des données stockées change. Sans gestion de version, les utilisateurs existants verront leur état ignoré ou pire, l'app crasher.
Pattern de versionnement :
const CURRENT_VERSION = 3;
interface VersionedStorage<T> {
version: number;
data: T;
}
// Dictionnaire de migrations : v1 → v2 → v3
const migrations: Record<number, (data: any) => any> = {
// v1 → v2 : on a renommé `lang` en `language`
1: (d: any) => ({ ...d, language: d.lang ?? 'fr', lang: undefined }),
// v2 → v3 : on a ajouté fontSize
2: (d: any) => ({ ...d, fontSize: d.fontSize ?? 16 })
};
function loadAndMigrate(): UserPreferences {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_PREFS;
const stored = JSON.parse(raw) as VersionedStorage<any>;
// Format ancien sans version : on assume v1
let { version = 1, data = stored } = stored;
// Appliquer chaque migration nécessaire
while (version < CURRENT_VERSION) {
const migrate = migrations[version];
if (!migrate) {
// Pas de migration disponible — fallback aux défauts
console.warn(`Version ${version} non supportée, reset.`);
return DEFAULT_PREFS;
}
data = migrate(data);
version++;
}
return data;
} catch {
return DEFAULT_PREFS;
}
}
// L'écriture inclut toujours la version courante
function save(prefs: UserPreferences) {
const wrapped: VersionedStorage<UserPreferences> = {
version: CURRENT_VERSION,
data: prefs
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(wrapped));
}
SSR et environnements sans localStorage
Côté serveur (SSR avec Angular Universal ou Hydration), localStorage n'existe pas. Tenter d'y accéder lève une ReferenceError qui casse le rendu serveur.
import { Injectable, PLATFORM_ID, inject, signal, effect } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class SafeStorageService {
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
readonly theme = signal<'light' | 'dark'>(this.loadInitial());
constructor() {
effect(() => {
// Ne persister que côté navigateur
if (this.isBrowser) {
localStorage.setItem('theme', this.theme());
}
});
}
private loadInitial(): 'light' | 'dark' {
if (!this.isBrowser) return 'light'; // SSR : valeur par défaut
return (localStorage.getItem('theme') as any) ?? 'light';
}
}
Hydration mismatch : attention au piège classique — le serveur rend en light, le client lit dark dans localStorage et bascule. Visuellement, l'utilisateur voit un flash de thème clair avant le passage au sombre. Solutions :
- Lire le thème depuis un cookie côté serveur (cookie envoyé au navigateur ET au serveur).
- Utiliser un
<script>inline en début de<head>qui ajoute la classe theme avant le rendu Angular. - Accepter le flash et utiliser
color-scheme: light dark+prefers-color-schemecomme fallback.
Cas réels : thème, panier, filtres
Cas 1 — Thème dark/light avec sync système
@Injectable({ providedIn: 'root' })
export class ThemeService {
readonly theme = persistedSignalSynced<'light' | 'dark' | 'system'>('app:theme', {
defaultValue: 'system'
});
// Thème effectivement appliqué
readonly resolvedTheme = computed(() => {
if (this.theme() !== 'system') return this.theme();
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
constructor() {
// Appliquer la classe au <html>
effect(() => {
document.documentElement.dataset.theme = this.resolvedTheme();
});
}
}
Cas 2 — Panier persistant entre sessions
@Injectable({ providedIn: 'root' })
export class CartService {
readonly items = persistedSignal<CartItem[]>('shop:cart', {
defaultValue: []
});
readonly count = computed(() => this.items().reduce((s, i) => s + i.qty, 0));
readonly total = computed(() => this.items().reduce((s, i) => s + i.price * i.qty, 0));
add(item: CartItem) {
this.items.update(list => {
const existing = list.find(i => i.id === item.id);
if (existing) {
return list.map(i => i.id === item.id ? { ...i, qty: i.qty + item.qty } : i);
}
return [...list, item];
});
}
clear() { this.items.set([]); }
}
Cas 3 — Filtres de recherche persistés (par page)
// Service générique pour persister les filtres d'une vue
export class FiltersService<F extends Record<string, any>> {
private filters: WritableSignal<F>;
constructor(viewKey: string, defaultFilters: F) {
this.filters = persistedSignal<F>(`filters:${viewKey}`, {
defaultValue: defaultFilters
});
}
get current() { return this.filters; }
set<K extends keyof F>(key: K, value: F[K]) {
this.filters.update(f => ({ ...f, [key]: value }));
}
reset(defaults: F) { this.filters.set(defaults); }
}
// Usage
@Component({ /* ... */ })
export class ProductListComponent {
filters = new FiltersService('products', {
category: '',
priceMin: 0,
priceMax: 1000,
sort: 'name' as const
});
}
- Toujours envelopper
JSON.parsedanstry/catch - Merger les valeurs lues avec les défauts pour les nouveaux champs
- Valider les données avec Zod sur les schémas complexes
- Inclure un numéro de version pour faciliter les migrations
- Vérifier
isPlatformBrowseravant tout accès SSR - Écouter
storageevent pour la sync multi-onglet - Debouncer les écritures fréquentes (input, drag, scroll)
- Ne jamais persister de données sensibles (tokens, mots de passe)
Conclusion : une couche persistante robuste
Combiner Signals et localStorage produit une couche de persistance minimaliste, type-safe et réactive sans dépendance externe. Quelques dizaines de lignes pour persistedSignal() remplacent un store global type Redux pour la majorité des cas : préférences utilisateur, panier, filtres, thème, brouillons.
À retenir pour la production :
- Validation au boot :
zodou unguardmanuel pour rejeter un payload corrompu et tomber sur la valeur par défaut. - Versionnage obligatoire : prévoir
{ v: 2, data: ... }dès le départ, ajouter une fonction de migration à chaque évolution du schéma. - SSR-safe :
typeof window === 'undefined'ouisPlatformBrowser()avant tout accès àlocalStorage. - Multi-onglet : écouter
window.storagepour synchroniser, mais éviter les boucles d'écriture (drapeau interne). - Jamais de secrets : tokens JWT, refresh tokens et données sensibles → cookies httpOnly côté serveur, jamais localStorage.
input()/model() pour propager l'état persistant aux composants enfants, et consultez la migration RxJS → Signals pour aligner le reste de votre stack réactive.