Using Angular Observable to subscribe to Session Storage key
Asked Answered
Y

4

5

I'm trying to accomplish what I had hoped would be a simple "look at the key in Session Storage and update it when it changes" scenario, but Observables really trip me up.

message$ = new Observable(observer => {
    observer.next(window.sessionStorage.getItem('message'));
});
    
ngOnInit() {
    this.message$.subscribe();
}

And the message$ is bound to the HTML, pretty straightforward:

<p id="message">{{message$ | async}}</p>

The Observable will output the text to the HTML if there's already value stored in 'message' but if there's no message on init and then it gets added, or the message value does exist and is updated, nothing happens. I'm obviously doing something wrong, and I would appreciate the insight of someone knowledgeable with Observables.

Yung answered 15/2, 2020 at 0:29 Comment(0)
K
15

I think what you're looking for is a StorageEvent

You can create your observable from this event using fromEvent, like this:

const message$ = fromEvent<StorageEvent>(window, "storage").pipe(
  filter(event => event.storageArea === sessionStorage),
  filter(event => event.key === "message"),
  map(event => event.newValue)
);

Notice that I added filters to specifically look for updates of sessionStorage and I specified the key === "message", but you can also use localStorage and any key you want

Also, as Damian C pointed out, you don't need to subscribe to this observable if you're planning to only use it in template.

EDITED: solution above only working when you have multiple tabs opened and want to synchronize changes to storage. Check this answer

I think you have to change a place where you set your value to sessionStorage. My suggestion is to use your own StorageService (my example) where you will set your value to storage and keep an observable, which should emit when you are setting key/value.

I did an example on stackblitz

https://stackblitz.com/edit/angular-3szmzd

Kamin answered 15/2, 2020 at 1:48 Comment(3)
So, fromEvent detects when the value changes in the message key in storage, and updates message$, which, as it's being subcribed to via template binding with an async pipe, the new value should automatically update in the DOM. But nothing happens, even when I refresh the page, which used to render any value if it was found, but no longer does. But I do like this approach and would like to better understand what I am missing.Yung
Yeah, you got it right. With fromEvent you can use any native browser events (like button click or, in this case, "storage" event), and turn them into observable. But I did a mistake about StorageEvent. As it turned out, this event only fires when you have multiple tabs opened. So the only solution is to trigger your own observable in the same time when you are setting new value to storage. I updated my answer with exampleKamin
Also, you can find the reason why I used ReplaySubject instead of Subject hereKamin
N
3

This thread is a bit old, but I just had the same problem.

As indicated by @quake, the storageEvent is not raised for changes within the same window.

To achieve the desired "settings interception" you have to patch the storage functions of the localSorage or sessionStorage. For example:

   // somewhere in a Class...

  private setStorageItemFunc: (key: string, value: string) => void;
  private getStorageItemFunc: (key: string) => string;
  private subject$: Subject<string>;

  applySettingsInterception() {
    // define a Subject
    this.subject$ = new Subject();

    // just for demonstation purposes. Don't subscribe to the subject, use async pipe instead
    this.subject$.subscribe(e => {
      console.log('value changed', e);
    });

    // save original functions
    this.setStorageItemFunc = window.localStorage.setItem;
    this.getStorageItemFunc = window.localStorage.getItem;

    // set storage functions to the newly created interceptor functions
    // bind to this to get access to the current object and it's values in interceptor function
    window.localStorage.setItem = this.setStorageItem.bind(this);
    window.localStorage.getItem = this.getStorageItem.bind(this);
  }

  private setStorageItem(key: string, value: string): void {
    console.log('setStorageItem', key, value);

    this.subject$.next(`${key}: ${value}`);

    this.setStorageItemFunc.call(localStorage, key, value);
  }

  private getStorageItem(key: string): string {
    const value = this.getStorageItemFunc.call(localStorage, key);

    console.log('getStorageItem', key, value);

    return value;
  }

As you can see, within the intercepted functions, you can call the next() function of an Subject as mentioned by @damian-c.

Nullify answered 4/9, 2020 at 9:16 Comment(1)
This looks beautiful. I'm gonna try thisGmt
P
2

Since you are using the async pipe, you don't need to subscribe in your component, you can take that out. AsyncPipe will handle the subscribe / unsubscribe behind the scenes.

Try using a subject instead.

// This is what will help you broadcast your message and push updates to subscribers.
message$ = new Subject();

// Anytime the "message" changes, you'll want to call this.
updateTheSubject() {
    this.message$.next(window.sessionStorage.getItem('message'));
}

Your html template can remain unchanged.

https://rxjs-dev.firebaseapp.com/guide/subject

https://angular.io/api/common/AsyncPipe

Pulchi answered 15/2, 2020 at 1:34 Comment(4)
just curious, why use a Subject instead of a new Observable if there is only one subscriber (or, AKA observer - and this is why Observables confuse me so badly)? In my case, there is only one subscriber, but the changes to the message key in session can come from anywhere in the app, so it would be best to have something watching that value for changes, as opposed to having to manually emit when the change occurs (which, if I'm understanding it, is what your updateTheSubject method would do).Yung
The API and setup is easier to work with when you aren't sure when and where the values will update. Your code in your original post will only ever emit a single value, the value of the session storage at that time. If Quake's answer works for you, id suggest using it as your first option, than mine as a back up.Pulchi
I appreciate the feedback and your time, it's really helpful for me in gaining some understanding. If I can continue the discussion here: if my original code only emits once, I thought observables tracked value changes over time. But my observable clealy does not, how does that work? If it's a type Observable, but does not observe, what's the deal?Yung
At that point I'd suggest hitting the Google and doing some simple tutorials outside of angular. RxJS and observables help you work with streams. They do not inherently create them ( though you can absolutely do that).Pulchi
R
1

Here is an alternate approach that works within the same window by patching sessionStorage.setItem. It is a strong approach, because it leverages the Browser's built-in event mechanism...no need to maintain your own state manually using an RxJS Subject.

Patch the sessionStorage.setItem function to manually generate a "storage" event within this window:

const origSetItem = window.sessionStorage.setItem;

window.sessionStorage.setItem = function setItem(key, value) {
  // Retrieve old value before we store the new one
  let oldValue = window.sessionStorage.getItem(key);

  // Store in SessionStorage
  const result = origSetItem.apply(this, arguments);

  // Manually fire a "storage" event so this window is alerted. On its own, 
  // sessionStorage.setItem() only fires a "storage" event for other tabs.
  const e = new StorageEvent('storage', {
    storageArea: window.sessionStorage,
    key,
    oldValue,
    newValue: value,
    url: window.location.href,
  });
  window.dispatchEvent(e);

  return result;
}

Provide a reusable mechanism for observing an entry in SessionStorage:

import { fromEvent, Observable } from 'rxjs';
import { filter } from 'rxjs/operators';

export const sessionStorageUtils = {
  observe,
};

/* Observe a specific entry in SessionStorage */
function observe(key: string): Observable<StorageEvent> {
  // Observe the window for "storage" events
  return fromEvent<StorageEvent>(window, 'storage')
    .pipe(
      filter((e: StorageEvent) => e.key === key),
      filter((e: StorageEvent) => e.newValue !== e.oldValue),
    );
}

Example Usage:

import { sessionStorageUtils } from './session-storage-utils';

// Don't forget to unsubscribe from this Observable OnDestroy
sessionStorageUtils.observe('testKey')
  .subscribe((data) => {
    console.log(data);
  });
Riviera answered 15/2, 2023 at 20:52 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.