Syncing LocalStorage In-Memory Cache With "storage" Events In Angular 2.1.1
CAUTION: Lately, I've been feeling very blocked, creatively. It's been quite hard to motiviate. So, this post is - at least in part - experimenting simply for the sake of experimenting; just trying to get the creative juices flowing.
I've been toying around with a little offline-first mobile app in Angular 2 that uses LocalStorage to persist data. Now, in addition to the storage limits of LocalStorage, I'm also concerned with the performance of reading from and writing to it (although, from what I've read recently, it seems to be less and less of an issue). To improve performance, I'd like to use an in-memory cache of the data and only read from the LocalStorage object when necessary. But, this would create synchronization problems across tabs in the same browser. Luckily, fellow InVision engineer, Shawn Grigson, taught me that the browser emits "storage" events when LocalStorage has been changed. I believe that I could use these events to keep the in-memory cache synchronized with the on-file data.
Run this demo in my JavaScript Demos project on GitHub.
It's hard to read the MDN documentation for StorageEvent and not believe that this event is exactly meant for syncing data. The window will emit a "storage" event when the LocalStorage object is changed; however, it won't do it all the time - it only emits the event when the LocalStorage is changed by another window or Tab. As such, we don't have to worry about filtering out events caused by the current page - the browser already handles that for us. Therefore, we can assume that the "storage" event is an indication that our in-memory cache is no longer reflective of on-file data.
And, it gets event better! When the StorageEvent is triggered, it passed the "newValue" of the LocalStorage key. As such, we don't even have to go back to the LocalStorage object to find the new value - we can immediately update the in-memory cache based on the newValue reported by the event itself.
NOTE: The StorageEvent is not exclusive to LocalStorage - it may be triggered by other storage areas.
To test this, I created a thin Angular 2 service that provides a simplified proxy API to the native LocalStorage service. This service handles the JSON serialization and parsing internally and maintains an in-memory cache for the portions of the LocalStorage that are relevant to the current Angular 2 application. But, more to the point, it binds to the "storage" event so that it can update the in-memory cache whenever the LocalStorage has been changed by another window or Tab.
interface ICache {
[ key: string ]: any
}
// I provide a proxy to the native localStorage object that operates on an in-memory
// cache and only reads from the localStorage as a fallback.
// --
// CAUTION: This services returns direct references to the cached data. This creates
// an inconsistent API in that it sometimes returns new values and sometimes returns
// shared values. This means that mutations to a returned value may or may not be
// observed in other parts of the code. It is advised that you DO NOT DIRECTLY MUTATE
// the values passed-to or returned-from this service.
export class LocalStorageService {
private cache: ICache;
private keyPrefix: string;
// I initialize the localStorage service.
constructor() {
this.cache = Object.create( null );
this.keyPrefix = "ng2-demo-"; // TODO: Inject this or use Module config phase.
// When the localStorage object is updated from ANOTHER WINDOW, pertaining to
// this origin, a "storage" event is triggered. This event, however, is NOT
// TRIGGERED if the current window updates the localStorage object. As such,
// we can use this event to update our in-memory cache of the localStorage
// content.
window.addEventListener( "storage", this.handleStorageEvent );
}
// ---
// PUBLIC METHODS.
// ---
// I return the localStorage item with the given key.
public getItem( key: string ) : any {
var normalizeKey = this.normalizeKey( key );
// If the item is already in the in-memory cache, return it directly.
// --
// CAUTION: Returning direct reference to cached value.
if ( normalizeKey in this.cache ) {
return( this.cache[ normalizeKey ] );
}
// If the item was not in the in-memory cache, we'll need to load it from the
// localStorage. However, to demonstrate that we are using AND SYNCHRONIZING
// the in-memory cache, let's log this fallback.
console.warn( "Reading from underlying localStorage." );
// Load, cache, and return the item.
// --
// CAUTION: Returning direct reference to cached value.
return( this.cache[ normalizeKey ] = JSON.parse( localStorage.getItem( normalizeKey ) ) );
}
// I store the item at the given key.
public setItem( key: string, value: any ) : void {
var normalizeKey = this.normalizeKey( key );
// Store it to both the in-memory cache and the localStorage. This way, when
// we read it later, we can read it directly from the in-memory cache.
// --
// CAUTION: Storing direct-reference to cached value.
this.cache[ normalizeKey ] = value;
localStorage.setItem( normalizeKey, JSON.stringify( value ) );
}
// ---
// PRIVATE METHODS.
// ---
// I handle the storage event triggered by localStorage changes from another process.
// I use this even to ensure that the in-memory cache is up-to-date.
// --
// CAUTION: Using property-binding here, NOT class method (a TypeScript feature).
private handleStorageEvent = ( event: StorageEvent ) : void => {
// Since our keys are name-spaced, we want to ignore any updates to the
// localStorage that fall outside of our usage.
if ( ! event.key.startsWith( this.keyPrefix ) ) {
return;
}
console.warn( "LocalStorage Event: [", event.key, "]" );
// Update the in-memory cache. Since the event contains the item itself, we can
// move that item directly into the in-memory cache rather than having to go
// back to the localStorage for a subsequent read.
if ( event.newValue === null ) {
delete( this.cache[ event.key ] );
} else {
this.cache[ event.key ] = JSON.parse( event.newValue );
console.table( this.cache[ event.key ] );
}
}
// I normalize the key for persistence.
private normalizeKey( key: string ) : string {
return( this.keyPrefix + key );
}
}
This service logs two things to the console: when it has to read from the underlying LocalStorage object - which is as little as possible; and, when the in-memory cache is updated based on a "storage" event.
Now, to experiment with this LocalStorage event in a meaningful way, I had to create some sort of non-trivial demo around it. In this case, I'll be persisting a list of Friends. The entire list is stored as a single item in the underlying LocalStorage; the following FriendService provides methods for manipulating that data.
// Import the core angular services.
import { Injectable } from "@angular/core";
import { Observable } from "rxjs/Observable";
// Import RxJS modules for "side effects".
import "rxjs/add/observable/of";
import "rxjs/add/observable/throw";
// Import the application components and services.
import { LocalStorageService } from "./local-storage.service";
export interface IFriend {
id: number;
name: string;
}
@Injectable()
export class FriendService {
private localStorageService: LocalStorageService;
// I initialize the friend service.
constructor( localStorageService: LocalStorageService ) {
this.localStorageService = localStorageService;
}
// ---
// PUBLIC METHODS.
// ---
// I create a new friend with the given name and return the new observable id.
public createFriend( name: string ) : Observable<number> {
var friends = this.loadFriends();
var friend = {
id: ( new Date() ).getTime(),
name: name
};
this.localStorageService.setItem( "friends", friends.concat( friend ) );
return( Observable.of( friend.id ) );
}
// I return an observable collection of friends.
public getFriends() : Observable<IFriend[]> {
return( Observable.of( this.loadFriends() ) );
}
// I remove the friend with the given id. Returns an observable confirmation.
public removeFriend( id: number ) : Observable<void> {
var friends = this.loadFriends();
var friendIndex = friends.findIndex(
( item: IFriend ) : boolean => {
return( item.id === id );
}
);
if ( friendIndex >= 0 ) {
friends = friends.slice();
friends.splice( friendIndex, 1 );
this.localStorageService.setItem( "friends", friends );
return( Observable.of( null ) );
} else {
return( Observable.throw( new Error( "Not Found" ) ) );
}
}
// ---
// PRIVATE METHODS.
// ---
// I load the friends collection from the persistent storage.
private loadFriends() : IFriend[] {
var friends = <IFriend[]>this.localStorageService.getItem( "friends" );
return( friends || [] );
}
}
If you look closely, each operation loads the collection of Friends from the LocalStorageService, updates it, and then persists it back into the LocalStorageService. However, it doesn't mutate the collection directly; instead, it creates a new collection, changes it, and then stores the collection. In this way, we're treating the collection of friends as immutable, which is important because we're caching the collection in-memory and then passing around direct-references to the in-memory objects.
NOTE: In the past, I've advocated for breaking direct-object references to cached objects by duplicating the data going into and out of the cache. Using that approach removes the burden-of-responsibility from the app code; but, in my current demo, I'm defering this responsibility to the app.
The root component then uses the FriendService to load and display the list of friends. For this exploration, I'm not proactively syncing the rendered list with the underlying storage; though, you could easily set up an Observable that watches for changes to the data. The goal of the root component was just to provide a UI (User Interface) that would allow me to easily change data across tabs.
// Import the core angular services.
import { Component } from "@angular/core";
import { OnInit } from "@angular/core";
// Import the application components and services.
import { FriendService } from "./friend.service";
import { IFriend } from "./friend.service";
@Component({
selector: "my-app",
template:
`
<p>
<a (click)="reload()">Reload Friends</a>
</p>
<ul *ngIf="friends.length">
<li *ngFor="let friend of friends">
{{ friend.name }} - <a (click)="remove( friend )">Remove</a>
</li>
</ul>
<input
type="text"
[value]="form.name"
(input)="form.name = $event.target.value"
(keydown.Enter)="addFriend()"
/>
<button type="button" (click)="addFriend()">Add Friend</button>
`
})
export class AppComponent implements OnInit {
public form: {
name: string;
};
public friends: IFriend[];
private friendService: FriendService;
// I initialize the component.
constructor( friendService: FriendService ) {
this.friendService = friendService;
this.friends = [];
this.form = {
name: ""
};
}
// ---
// PUBLIC METHODS.
// ---
// I add a new friend using the form field value.
public addFriend() : void {
this.friendService
.createFriend( this.form.name )
.subscribe(
( id: number ) : void => {
this.form.name = "";
this.reload();
}
)
;
}
// I get called once after the component has been initialized and the inputs have
// been bound for the first time.
public ngOnInit() : void {
this.reload();
}
// I reload the list of friends.
public reload() : void {
this.friendService
.getFriends()
.subscribe(
( friends: IFriend[] ) : void => {
this.friends = friends;
}
)
;
}
// I remove the given friend from the collection.
public remove( friend: IFriend ) : void {
// Optimistically remove from local collection.
this.friends = this.friends.filter(
( value: IFriend ) : boolean => {
return( value !== friend );
}
);
this.friendService
.removeFriend( friend.id )
.subscribe(
() : void => {
this.reload();
}
)
;
}
}
As you can see, I can render the list, add new friends, and remove existing friends. If I open this Angular 2 application in two different tabs in the same browser, and then make changes in one tab, you will see the other tab log the changes that were emitted with the "storage" event:
As you can see from the logging, the current Tab only reads from the LocalStorage once, upon initial load. After that, it just responds to the "storage" event in order to keep the in-memory cache synchronized with the on-file data. At this point, if I click the "Reload Friends" link, the new friends would show up immediately; but, it's not worth another screenshot.
The "storage" event seems like the perfect way to keep an in-memory cache synchronized with the on-file data for LocalStorage. That said, as a final note, I should mention that LocalStorage isn't always available. And, even when it's available, it may not have any available storage. That's why I think abstracting it is always a good idea, to some degree, even if you're not caching data in memory.
Want to use code from this post? Check out the license.
Reader Comments
Great article
one Question related to sharing session Storage across tab, I create service where I manage the toke. If user not check the remember me than I store token in session Storage and want to share across the tab ,I have issue with storage event where I initialize in angular 2 app?
it share the token across the tab but still login-resolver invoke before the initialize and persist the user to login when I refresh that tab again the app is working fine because the token is already there.
One thing more when running the app in debugging mode, the initialize function is invoke before the login-resolver and when i pass through the code it working fine. but when the debugging mode is off the app go to the login page.
Thanks in advance
@Shahid,
That is strange; especially if you see the initialize function running *before* the login resolver. I assume the login resolve is a "route resolver"? To be honest, I don't have much experience with the route resolving in Angular 2. I've looked at it a tiny bit; but not enough to have a good understanding. Where are you calling the initializer?
This article was extremely good and idea you implemented it was really nice and reusable but the problem is, this code was not working in IE9+ when integrated with angular 4 due to usage of 'addEventListener', rest other browsers working good.
can you please help me with solution ?