Synchronized Web Storage with Signals
Tags: angular, webdev, javascript
Published: 2024-06-18 06:15:06

Working with local storage in JavaScript is both very easy and sometimes frustrating. While being really straightforward, the [Web `Storage` API](https://developer.mozilla.org/en-US/docs/Web/API/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:

![Final Result GIF](https://raw.githubusercontent.com/pBouillon/DEV.StorageSignal/main/projects/demo/public/final-result.gif)

> All the code of this article is [hosted on GitHub](https://github.com/pBouillon/DEV.StorageSignal) 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](https://angular.dev/guide/di/lightweight-injection-tokens#using-lightweight-injection-tokens) for the kind of storage to use:

```ts
// 📂 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:

```ts
// 📂 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):

```ts
// 📂 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](https://dev.to/this-is-angular/efficient-angular-effects-patterns-4396).

Before diving into read and write synchronization, let's first create the utility function:

```ts
// 📂 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:

```ts
// 📂 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](https://angular.dev/guide/signals#signal-equality-functions)):

```ts
// 📂 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:

```ts
// 📂 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:
> ```ts
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](https://developer.mozilla.org/en-US/docs/Web/API/Window/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:

```ts
// 📂 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:

```ts
// 📂 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](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 (...)

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:

```ts
// 📂 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:

![Final Result GIF](https://raw.githubusercontent.com/pBouillon/DEV.StorageSignal/main/projects/demo/public/final-result.gif)

If you would like to play with the code yourself, check out [the code on GitHub](https://github.com/pBouillon/DEV.StorageSignal)!

---

I hope your learned something useful!

{% embed https://dev.to/pbouillon %}

> Photo by <a href="https://unsplash.com/@chuttersnap?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">CHUTTERSNAP</a> on <a href="https://unsplash.com/photos/brown-cardboard-boxes-on-white-metal-rack-BNBA1h-NgdY?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>