Syncing Angular Signals to LocalStorage (The Clean Way)
I spent three hours last Tuesday tracking down a bug where a user’s theme preference kept resetting to “light” on page reload. The culprit was a single missed RxJS next() call hidden deep in a legacy service. It is exactly the kind of state management headache that makes you want to close your laptop and walk away.
Well, we fixed it. But it got me thinking about how much boilerplate we still write just to keep a basic UI state synced with the browser. You update a variable, stringify it, push it to localStorage, and hope you didn’t miss a spot.
And with Angular 19.2, we have a much better primitive for this. Signals combined with the effect() function make persistent state almost completely hands-off. You just need to wire it up once using generics.
The basic (but flawed) approach
My first instinct was to just write a quick wrapper function. Something that takes an initial value, checks storage, and returns a Signal. Every time the Signal changes, an effect updates the storage.
Here is what that looks like:
import { signal, effect, WritableSignal } from '@angular/core';
export function createPersistedSignal<T>(key: string, initialValue: T): WritableSignal<T> {
const stored = localStorage.getItem(key);
const parsed = stored ? JSON.parse(stored) as T : initialValue;
const sig = signal<T>(parsed);
effect(() => {
localStorage.setItem(key, JSON.stringify(sig()));
});
return sig;
}
Looks perfectly fine. You throw this into a component, call createPersistedSignal('user-settings', defaultSettings), and — well, that’s not entirely accurate. If you actually run that code, Angular throws NG0203: effect() can only be used within an injection context.
The Injection Context Gotcha
Effects need to know what component or service they belong to so they can be cleaned up when that context is destroyed. And if you call our utility function inside a random event handler? Boom. Error.
The SSR Nightmare
Server-Side Rendering (SSR) is default in modern Angular. And when your app renders on the server, localStorage does not exist.
I tested the naive implementation on my M2 MacBook running Node 22.4.0. The SSR build immediately threw a ReferenceError: localStorage is not defined and killed the node process. You have to check the platform before touching browser APIs.
Here is the production-ready service I ended up writing. It handles the platform check, keeps the generics for type safety, and safely registers the effect.
import { Injectable, PLATFORM_ID, signal, effect, WritableSignal, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable({
providedIn: 'root'
})
export class StorageSyncService {
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
createSignal<T>(key: string, initialValue: T): WritableSignal<T> {
let startingValue = initialValue;
if (this.isBrowser) {
const stored = localStorage.getItem(key);
if (stored !== null) {
try {
startingValue = JSON.parse(stored) as T;
} catch (e) {
console.error(Failed to parse storage key "${key}", e);
}
}
}
const stateSignal = signal<T>(startingValue);
if (this.isBrowser) {
effect(() => {
const currentValue = stateSignal();
localStorage.setItem(key, JSON.stringify(currentValue));
});
}
return stateSignal;
}
}
How to actually use it
Now you just inject the service and initialize your state. Because we are using TypeScript generics (<T>), you get full autocomplete for whatever object structure you pass in.
import { Component, inject } from '@angular/core';
import { StorageSyncService } from './storage-sync.service';
interface UserPreferences {
theme: 'light' | 'dark';
compactMode: boolean;
}
@Component({
selector: 'app-settings',
template:
<button (click)="toggleTheme()">
Current theme: {{ prefs().theme }}
</button>
})
export class SettingsComponent {
private storage = inject(StorageSyncService);
// The generic type is inferred from the initial value!
prefs = this.storage.createSignal<UserPreferences>('ui-prefs', {
theme: 'light',
compactMode: false
});
toggleTheme() {
this.prefs.update(current => ({
...current,
theme: current.theme === 'light' ? 'dark' : 'light'
}));
// No manual save required. The effect handles it.
}
}
I migrated a complex filtering sidebar to this pattern yesterday. We went from about 45 lines of RxJS subscriptions and manual storage checks down to exactly 4 lines of Signal initialization. The performance is identical, but the cognitive load is zero.
Just keep an eye on your payload size. Browsers cap localStorage at around 5MB. If you try to sync a massive data grid using this effect pattern, you will hit a QuotaExceededError faster than you think. Keep it limited to user preferences, UI states, and small cached datasets.
