Front-end angularforall.com

- Signals + localStorage : couche de persistance

Angular Signals Localstorage Persistance Effect Storage-Event Ssr Zod Migration Multi-Onglet Preferences Angular-19
Signals + localStorage : couche de persistance

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
Quand ne pas utiliser ce pattern : pour des données volumineuses (>1 Mo), des données partagées entre utilisateurs, ou des écritures très fréquentes (chaque keystroke). Préférez IndexedDB ou un backend dans ces cas.

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);
    }
}
Spread + DEFAULT_PREFS : le merge avec les valeurs par défaut au chargement est crucial. Quand vous ajoutez un nouveau champ dans une nouvelle version, les utilisateurs existants n'auront pas ce champ dans leur localStorage. Le spread garantit qu'il prend la valeur par défaut.

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([]);
    }
}
Avantage clé : ce helper transforme la persistance en détail d'implémentation invisible. Le composant ne sait pas qu'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;
    }
}
Pourquoi valider : imaginez que votre app sort en v2 avec 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 storage ne se déclenche pas dans l'onglet qui écrit. C'est exactement le comportement souhaité.
  • Sans untracked(), le sig.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.key est crucial : tous les changements de localStorage déclenchent l'événement.
Cas avancé : pour synchroniser entre onglets et au sein du même onglet (autre service modifie la clé), utilisez plutôt 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));
}
Stratégie de fallback : en production, si une migration échoue, ne crashez jamais. Logger l'erreur dans votre service de monitoring (Sentry, Rollbar) et appliquer les valeurs par défaut. L'utilisateur perd ses préférences mais l'app fonctionne.

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-scheme comme 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.parse dans try/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 isPlatformBrowser avant tout accès SSR
  • Écouter storage event pour la sync multi-onglet
  • Debouncer les écritures fréquentes (input, drag, scroll)
  • Ne jamais persister de données sensibles (tokens, mots de passe)
Pour aller plus loin : consultez la documentation officielle angular.dev — Signals et l'article original sur blog.angulartraining.com.

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 : zod ou un guard manuel 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' ou isPlatformBrowser() avant tout accès à localStorage.
  • Multi-onglet : écouter window.storage pour 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.
Pour aller plus loin : chaînez ce pattern avec la nouvelle API 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.

Partager