Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Example on how to achieve RxJS observable caching and storage in Angular 2+. Ideal for storing Http requests client side for offline usage.
import { Component, OnInit, OnDestroy } from '@angular/core';
import {Http} from "@angular/http";
import { LocalCacheService } from "./local-cache.service";
@Component({
selector: 'app-example',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class ExampleComponent implements OnInit, OnDestroy {
constructor(public http:Http, public cache:LocalCacheService){
//Cache an observable
let requestObservable = this.http.get("http://example.com/path/to/api").map(res => res.json())
this.cache.observable('my-cache-key', requestObservable, 300).subscribe(result => {
//Use result
console.log(result)
});
}
}
import {Injectable} from "@angular/core";
import {LocalStorageService} from "./local-storage.service";
import {Observable} from "rxjs/Observable";
import {isEmpty, isString, isNumber, isDate} from 'lodash';
@Injectable()
export class LocalCacheService {
/**
* Default expiry in seconds
*
* @type {number}
*/
defaultExpires: number = 86400; //24Hrs
constructor(private localstorage: LocalStorageService) {}
/**
* Cache or use result from observable
*
* If cache key does not exist or is expired, observable supplied in argument is returned and result cached
*
* @param key
* @param observable
* @param expires
* @returns {Observable<T>}
*/
public observable<T>(key: string, observable: Observable<T>, expires:number = this.defaultExpires): Observable<T> {
//First fetch the item from localstorage (even though it may not exist)
return this.localstorage.getItem(key)
//If the cached value has expired, nullify it, otherwise pass it through
.map((val: CacheStorageRecord) => {
if(val){
return (new Date(val.expires)).getTime() > Date.now() ? val : null;
}
return null;
})
//At this point, if we encounter a null value, either it doesnt exist in the cache or it has expired.
//If it doesnt exist, simply return the observable that has been passed in, caching its value as it passes through
.flatMap((val: CacheStorageRecord | null) => {
if (!isEmpty(val)) {
return Observable.of(val.value);
} else {
return observable.flatMap((val:any) => this.value(key, val, expires)); //The result may have 'expires' explicitly set
}
})
}
/**
* Cache supplied value until expiry
*
* @param key
* @param value
* @param expires
* @returns {Observable<T>}
*/
value<T>(key:string, value:T, expires:number|string|Date = this.defaultExpires):Observable<T>{
let _expires:Date = this.sanitizeAndGenerateDateExpiry(expires);
return this.localstorage.setItem(key, {
expires: _expires,
value: value
}).map(val => val.value);
}
/**
*
* @param key
* @returns {Observable<null>}
*/
expire(key:string):Observable<null>{
return this.localstorage.removeItem(key);
}
/**
*
* @param expires
* @returns {Date}
*/
private sanitizeAndGenerateDateExpiry(expires:string|number|Date):Date{
let expiryDate:Date = this.expiryToDate(expires);
//Dont allow expiry dates in the past
if(expiryDate.getTime() <= Date.now()){
return new Date(Date.now() + this.defaultExpires);
}
return expiryDate;
}
/**
*
* @param expires
* @returns {Date}
*/
private expiryToDate(expires:number|string|Date):Date{
if(isNumber(expires)){
return new Date(Date.now() + Math.abs(expires)*1000);
}
if(isString(expires)){
return new Date(expires);
}
if(isDate(expires)){
return expires;
}
return new Date();
}
}
/**
* Cache storage record interface
*/
interface CacheStorageRecord {
expires: Date,
value: any
}
import * as localforage from 'localforage';
import {Injectable} from "@angular/core";
import {Observable} from "rxjs/Observable";
@Injectable()
export class LocalStorageService {
/**
*
* @param key
* @param value
* @returns {any}
*/
public setItem<T>(key:string, value:T):Observable<T>{
return Observable.fromPromise(localforage.setItem(key, value))
}
/**
*
* @param key
* @returns {any}
*/
public getItem<T>(key:string):Observable<T>{
return Observable.fromPromise(localforage.getItem(key))
}
/**
*
* @param key
* @returns {any}
*/
public removeItem(key:string):Observable<void>{
return Observable.fromPromise(localforage.removeItem(key))
}
}
@Pierozi

This comment has been minimized.

Copy link

@Pierozi Pierozi commented Jul 21, 2017

Hi, Great stuff! you should package it.

@dgroh

This comment has been minimized.

Copy link

@dgroh dgroh commented Aug 30, 2017

+1 for packaging it...

@jeusdi

This comment has been minimized.

Copy link

@jeusdi jeusdi commented Sep 1, 2017

Have you already packaged it?

@bhaidar

This comment has been minimized.

Copy link

@bhaidar bhaidar commented Sep 11, 2017

Hello,

I have been using this solution for a month now. Today I received this exception. My Chrome is now v 61.

I believe this has to do with the Local Forage.

Any clue? Thanks

     var b = number % 10,
         output = (toInt(number % 100 / 10) === 1) ? 'th' :...<omitted>... } could not be cloned.
    at http://localhost:4200/vendor.bundle.js:10127:29
    at ZoneDelegate.webpackJsonp.../../../../zone.js/dist/zone.js.ZoneDelegate.invoke (http://localhost:4200/polyfills.bundle.js:7073:26)
    at Object.onInvoke (http://localhost:4200/vendor.bundle.js:136945:33)
    at ZoneDelegate.webpackJsonp.../../../../zone.js/dist/zone.js.ZoneDelegate.invoke (http://localhost:4200/polyfills.bundle.js:7072:32)
    at Zone.webpackJsonp.../../../../zone.js/dist/zone.js.Zone.run (http://localhost:4200/polyfills.bundle.js:6833:43)
    at http://localhost:4200/polyfills.bundle.js:7466:57
    at ZoneDelegate.webpackJsonp.../../../../zone.js/dist/zone.js.ZoneDelegate.invokeTask (http://localhost:4200/polyfills.bundle.js:7106:31)
    at Object.onInvokeTask (http://localhost:4200/vendor.bundle.js:136936:33)
    at ZoneDelegate.webpackJsonp.../../../../zone.js/dist/zone.js.ZoneDelegate.invokeTask (http://localhost:4200/polyfills.bundle.js:7105:36)
    at Zone.webpackJsonp.../../../../zone.js/dist/zone.js.Zone.runTask (http://localhost:4200/polyfills.bundle.js:6873:47)
    at drainMicroTaskQueue (http://localhost:4200/polyfills.bundle.js:7299:35)
    at XMLHttpRequest.ZoneTask.invoke (http://localhost:4200/polyfills.bundle.js:7172:25)````
@jasonhodges

This comment has been minimized.

Copy link

@jasonhodges jasonhodges commented Oct 20, 2017

I am receiving the following error:
ERROR TypeError: this.localstorage.getItem(...).map(...).flatMap is not a function at LocalCacheService.observable (local-cache.service.ts:61)

Any help would be much appreciated! Great work

@gilhanan

This comment has been minimized.

Copy link

@gilhanan gilhanan commented Jan 1, 2018

thanks for sharing
i pretty the code so please take a look and update if you like to

@nahuelhds

This comment has been minimized.

Copy link

@nahuelhds nahuelhds commented Feb 21, 2018

+1 for packaging.

Great stuff!

@abyken

This comment has been minimized.

Copy link

@abyken abyken commented Jun 6, 2018

Hello,

Could you, please, explain how to update cache value from received data of PUT request.

I need to do thing like that:

this.cache.observable('app', this.appProvider.retrieveApp(), 8468)
  .subscribe(app => {
    this.app = app;
  });


this.cache.observable('app', this.appProvider.updateApp(appForm), 8468)
  .subscribe(app => {
    this.app = app;
  });

Thank you!

@51303014

This comment has been minimized.

Copy link

@51303014 51303014 commented Sep 28, 2018

Hello,
Could you please support RXJS v6 with error:
TS2345: Argument of type 'OperatorFunction<CacheStorageRecord, CacheStorageRecord | null>' is not assignable to parameter of type 'OperatorFunction<{}, CacheStorageRecord | null>'.   Type '{}' is not assignable to type 'CacheStorageRecord'.     Property 'expires' is missing in type '{}'.

public observable(key: string, observable: Subscription, expires: number = this.defaultExpires): Observable {
return this.localstorage.getItem(key).pipe(
map((val: CacheStorageRecord) => {
if(val){
return (new Date(val.expires)).getTime() > Date.now() ? val : null
}
return null
}),
flatMap((val: CacheStorageRecord | null) => {
if (!isEmpty(val)) {
return of(val.value)
} else {
return flatMap((data:any) => this.value(key, data, expires)) //The result may have 'expires' explicitly set
}
}))
}
Thank you

@wengng

This comment has been minimized.

Copy link

@wengng wengng commented Dec 17, 2018

Hi there, please advise what's "import * as localforage from 'localforage';" in the local-storage.service.ts? Is it a typo?

@Kevinlearynet

This comment has been minimized.

Copy link

@Kevinlearynet Kevinlearynet commented Jan 7, 2019

@wengng

It's the localforage package, which this relies on as a dependency.

@zarpilla

This comment has been minimized.

Copy link

@zarpilla zarpilla commented Feb 1, 2019

Hi,
For RXJS v6:

import {of as _observableOf} from 'rxjs';
import { mergeMap as _observableMergeMap, catchError as _observableCatch } from 'rxjs/operators';
...

  public observable<T>(key: string, observable: Observable<T>, expires:number = this.defaultExpires): Observable<T> {
    //First fetch the item from localstorage (even though it may not exist)
    return this.localstorage.getItem(key)
      //If the cached value has expired, nullify it, otherwise pass it through
      .map((val: CacheStorageRecord) => {
        if(val){
          return (new Date(val.expires)).getTime() > Date.now() ? val : null;
        }
        return null;
      })
      //At this point, if we encounter a null value, either it doesnt exist in the cache or it has expired.
      //If it doesnt exist, simply return the observable that has been passed in, caching its value as it passes through
      .pipe(_observableMergeMap((val: CacheStorageRecord | null) => {
        if (!isEmpty(val)) {
          return _observableOf(val.value);
        } else {
          return observable.pipe(_observableMergeMap((val:any) => this.value(key, val, expires))); //The result may have 'expires' explicitly set
        }
      }))
  }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.