Wrapping the Web Storage Layer
Working with the Web Storage API in JavaScript is both pleasantly simple and occasionally frustrating. While the API itself is straightforward, the Web Storage API lacks a feature that has become essential in modern Angular applications: reactivity.
Fortunately, the latest Angular release gives us plenty of building blocks to craft a utility function that turns a locally stored value into a reactive signal.
In this article, we will explore how to encapsulate web storage inside a dedicated Angular service, and then leverage that abstraction to synchronize a signal with the stored value, ultimately producing this behavior:
The complete source code for this article is available on GitHub.
Isolating the Storage API
Before we jump into the signal implementation, we first need to create a thin wrapper around the native storage interface.
This layer of indirection lets us control exactly what we read and write, gives us the freedom to swap between different storage implementations, and makes mocking for unit tests far more convenient.
Choosing the Storage Backend
The first step is to register an injection token that identifies which storage flavor we intend to use:
// 📂 storage.service.ts
export const STORAGE = new InjectionToken<Storage>(
'Web Storage Injection Token'
);
With this token in place, we can now provide the desired Storage implementation (localStorage, sessionStorage, or any other) to the Angular application:
// 📂 main.ts
bootstrapApplication(AppComponent, {
providers: [{ provide: STORAGE, useValue: localStorage }],
}).catch((err) => console.error(err));
Building the Storage Service
Once our storage implementation is available in the DI container, we can inject it into a service and build methods for reading and writing — while also applying a reasonable degree of type safety:
// 📂 storage.service.ts
@Injectable({ providedIn: 'root' })
export class StorageService {
readonly #storage = inject(STORAGE);
getItem<T>(key: string): T | null {
const raw = this.#storage.getItem(key);
return raw === null
? null
: JSON.parse(raw) as T;
}
setItem<T>(key: string, value: T | null): void {
const stringified = JSON.stringify(value);
this.#storage.setItem(key, stringified);
}
}
With those building blocks in place, we're ready to construct the reactive layer.
Building the Signal Bridge
Signals are pleasantly simple to consume, yet they are also remarkably straightforward to wrap and extend.
Let's begin by writing the utility function that reads an initial value from storage:
// 📂 from-storage.function.ts
export const fromStorage = <TValue>(storageKey: string): WritableSignal<TValue | null> => {
const storage = inject(StorageService);
const initialValue = storage.getItem<TValue>(storageKey);
return signal<TValue | null>(initialValue);
}
At this stage, `fromStorage` merely sets up a signal based on the value found (or missing) in the injected storage for a given key.
We can now call fromStorage and track both the key and its typed value:
// 📂 app.component.ts
type ColorScheme = 'light' | 'dark';
@Component({ /*...*/ })
export class AppComponent {
readonly preferredTheme = fromStorage<ColorScheme>('preferred-theme');
}
This is a good start, but two important pieces of reactivity are still missing:
- Whenever the signal's value changes, the stored value should be updated accordingly
- Whenever external changes are made to the storage under this key, the signal should reflect those
Let's solve those one by one.
Keeping Storage in Sync with Writes
The simplest part is updating the storage whenever we assign a new value to our signal.
Using an effect, we can invoke StorageService.setItem each time the value actually changes, as the effect will be re-executed whenever the dependency updates (or the equality check passes):
// 📂 from-storage.function.ts
export const fromStorage = <TValue>(storageKey: string): WritableSignal<TValue | null> => {
// ...
const fromStorageSignal = signal<TValue | null>(initialValue);
const writeToStorageOnUpdateEffect = effect(() => {
const updated = fromStorageSignal();
untracked(() => storage.setItem(storageKey, updated));
});
return fromStorageSignal;
}
That handles one direction of the sync nicely.
Synchronizing Reads: Leveraging the Web Storage API
The core difficulty arises from the fact that changes can happen in two situations beyond our direct control:
- Other code within the same application modifies the stored data
- A separate browser tab changes the same stored value
To handle this, we need a mechanism to detect external modifications, even those originating outside our application's scope.
Using setTimeout
One initial approach that might come to mind is to use setTimeout.
While theoretically possible, this strategy has significant drawbacks. A longer polling interval would introduce a noticeable delay in updates, while a shorter one would create a heavy polling load. This problem is compounded when tracking multiple keys, as it would require managing numerous concurrent polling loops.
A basic implementation of this concept is shown here:
// 📂 from-storage.function.ts
export const fromStorage = <TValue>(storageKey: string): WritableSignal<TValue | null> => {
// ...
const updateSignalOnSignalWriteEffect = effect((onCleanup) => {
const intervalId = setInterval(() => {
const newValue = storage.getItem<TValue>(key);
const currentValue = fromStorageSignal();
const hasValueChanged = newValue !== currentValue;
if (hasValueChanged) fromStorageSignal.set(newValue);
}, 150)
onCleanup(() => clearInterval(intervalId));
});
return fromStorageSignal;
}
🚨 In applications still relying on zonejs for change detection, it's crucial to execute this polling outside the Angular zone. Doing so prevents unnecessary change detection cycles and improves performance:
inject(NgZone).runOutsideAngular(() => /*...*/ );
Using the storage event
Rather than continuously polling for changes, what if we could directly respond to them? The Web Storage API offers exactly that through the Storage Event. This event, accessible via storage.onstorage or by listening for the storage event, is triggered whenever an entry in the Storage is altered. It provides details about which key was changed, along with other relevant information.
This seems perfect, so let's adopt this approach:
// 📂 from-storage.function.ts
export const fromStorage = <TValue>(storageKey: string): WritableSignal<TValue | null> => {
// ...
const storageEventListener = (event: StorageEvent) => {
const isWatchedValueTargeted = event.key === storageKey;
if (!isWatchedValueTargeted) {
return;
}
const currentValue = fromStorageSignal();
const newValue = storage.getItem<TValue>(storageKey);
const hasValueChanged = newValue !== currentValue;
if (hasValueChanged) {
fromStorageSignal.set(newValue);
};
}
window.addEventListener('storage', storageEventListener);
// 👇 Don't forget to clean up after yourself
inject(DestroyRef).onDestroy(() => {
window.removeEventListener('storage', storageEventListener);
});
return fromStorageSignal;
}
Let's put this into practice:
// 📂 app.component.ts
@Component({ /*...*/ })
export class AppComponent {
readonly preferredTheme1 = fromStorage<ColorScheme>('preferred-theme');
togglePreferredTheme(): void {
this.preferredTheme1.update(current => current === 'light' ? 'dark' : 'light');
}
readonly preferredTheme2 = fromStorage<ColorScheme>('preferred-theme');
setLightTheme(): void {
this.preferredTheme2.set('light');
}
}
Wait—the setLightTheme function fails to update preferredTheme1, and togglePreferredTheme has no impact on preferredTheme2! I was under the impression this was just resolved.
The answer lies in the [MDN documentation of the event](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event):
Note: This won't work on the same browsing context that is making the changes (...)
In essence, the storage event isn't fired in the same tab that executes the write operation. It seems we were almost at the point of having a truly reactive value!
The good news is that we can create our own StorageEvent. Since we have our dedicated service for interacting with the storage, we can manually trigger this event within our own tab after each write operation:
// 📂 storage.service.ts
@Injectable({ providedIn: 'root' })
export class StorageService {
readonly #storage = inject(STORAGE);
getItem<T>(key: string): T | null { /*...*/ }
setItem<T>(key: string, value: T | null): void {
const stringified = JSON.stringify(value);
this.#storage.setItem(key, stringified);
// 👇 Notify of the update
const storageEvent = new StorageEvent('storage', {
key: key,
newValue: stringified,
storageArea: this.#storage,
});
window.dispatchEvent(storageEvent);
}
}
🚨 Be aware that this can duplicate events for the other tabs, hence the need of checking if the value has changed in the event handler to avoid any issue.
When we call togglePreferredTheme or setLightTheme again, we observe that both signals are now correctly updated, just like the value in the Storage. We've finally accomplished our goal!
Final Thoughts
This guide has walked through abstracting both the Storage itself and the interaction with the Web Storage API to gain full control over its usage. We then implemented a method to generate a signal from a specific key, ensuring that the signal and the value in the Storage stay perfectly in sync:
If you'd like to experiment with the final code, feel free to explore the repository on GitHub!
I trust this has been an informative read!
Photo by CHUTTERSNAP on Unsplash

