DEV Community

Cover image for Synchronized Web Storage with Signals
Pierre Bouillon for This is Angular

Posted on

Synchronized Web Storage with Signals

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:

Final Result GIF

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'
);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

🚨 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;
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

🚨 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

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)

Collapse
 
jangelodev profile image
João Angelo

Top, very nice !
Thanks for sharing