Working with local storage in JavaScript is both very easy and sometimes frustrating. While being really straightforward, the Web Storage
API lack of one critical feature that is more deeply rooted than ever in modern Angular applications: reactivity.
Fortunately, with the latest release, many tools are at our disposal to create a handy utility function, creating a reactive signal from a value stored locally!
In this article, we will see how to abstract the web storage in a dedicated Angular service, and how to take advantage of it to synchronize its value using signals to achieve the following outcome:
All the code of this article is hosted on GitHub if you would like to check it out!
Abstracting the Web Storage
Before actually building the signal's logic, we will first have to abstract the native Web Storage API.
This will allow us to chose what we are manipulating, how, as well as giving us more flexibility on what kind of storage we would use (not to mention an easier way of mocking it for testing purposes).
Dynamic Storage Type
The first thing we would like to do is to define an injection token for the kind of storage to use:
// 📂 storage.service.ts
export const STORAGE = new InjectionToken<Storage>(
'Web Storage Injection Token'
);
From now we can provide the desired Storage
(localStorage
, sessionStorage
, etc.) to our Angular application:
// 📂 main.ts
bootstrapApplication(AppComponent, {
providers: [{ provide: STORAGE, useValue: localStorage }],
}).catch((err) => console.error(err));
Creating the StorageService
Since the Storage
is now known in the injection container, we can consume it in an Angular service to manage the read and write operation, while also enforcing type safety (within a certain limit):
// 📂 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);
}
}
Now that the basic building blocks are ready, we can work on the actual signals!
Leveraging Signals
Signals are really easy to use but also really easy to wrap and extend.
Before diving into read and write synchronization, let's first create the utility function:
// 📂 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);
}
For now, this only create a signal
from a value (or its absence) in the injected Storage
.
Upon calling fromStorage
, we can now track a specific key and its (typed) value:
// 📂 app.component.ts
type ColorScheme = 'light' | 'dark';
@Component({ /*...*/ })
export class AppComponent {
readonly preferredTheme = fromStorage<ColorScheme>('preferred-theme');
}
This looks promising, but we still lack of that desired reactivity in two major cases:
- When updating the value, we should also update the stored value
- When any update is made to the storage for this key, we should also update the value
Let's address those!
Syncing Writes
Updating the value in the Storage
whenever we are updating the signal
's value is the easiest part.
With effect
we can simply call StorageService.setItem
to write the updated value since it will be invoked when the value actually changes (or matches the defined equality):
// 📂 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's one problem solved!
Syncinc Reads: Taking Advantage of the Web Storage API
The main issue here is that the update can occur in two cases that we can't always control:
- Another piece of code updates the stored value
- Another tab updates the same value
We will need a way of detecting that something changed, possibly outside of our app.
Using setTimeout
A first solution we might think of would be using setTimeout
.
While possible, it would either introduce a latency in the update with a long period, or a heavy polling mechanism if we chose a smaller one (not to mention that watching several values would then introduce a lot of those polling systems).
Such a system could be implemented as:
// 📂 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 that are still using zonejs for change detection, you should run this outside zone to avoid triggering unecessary change detection:
inject(NgZone).runOutsideAngular(() => /*...*/ );
Using the storage event
What if instead of a polling mechanism we could react to a change? Fortunately, the Web Storage API defines a Storage Event that can be listened to using storage.onstorage
or the storage
event, which tells us that something in the Storage
has been modified, what key was targeted and some more information.
Sounds great, let's use that instead:
// 📂 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;
}
Great! Let's try it:
// 📂 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');
}
}
Calling setLightTheme
does not update preferredTheme1
and togglePreferredTheme
has no effect of preferredTheme2
! I thought we just solved this problem?
The explanation is actually on the MDN doc page:
Note: This won't work on the same browsing context that is making the changes (...)
It means that updating the value ourselves won't trigger the event in our own tab. We were so close to a reactive value!
Fortunately, the StorageEvent
can be crafted and we have our own service to interact with the storage
, meaning that we can raise that event ourselves upon write:
// 📂 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.
If we try again to call togglePreferredTheme
or setLightTheme
, we can see that both signals are now indeed updated along with the value in the Storage
. We did it!
Wrapping up
In this article we abstracted both the Storage
and the interaction with the Web Storage API in order to have control of its usage. We then introduced a method to create a signal from a key to watch a value in the Storage
, achieving synchronization between signals and the Web Storage:
If you would like to play with the code yourself, check out the code on GitHub!
I hope your learned something useful!
Photo by CHUTTERSNAP on Unsplash
Top comments (1)
Top, very nice !
Thanks for sharing