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
        }
      }))
  }

@atdetquizan

This comment has been minimized.

Copy link

@atdetquizan atdetquizan commented Mar 11, 2021

Hello, I share the updated code with the current version of rxjs

A small change was made, the storage was placed in a variable, so we can change it if it is a session or local storage
localStorage | sessionStorage

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';

@Injectable({
    providedIn: 'root',
})
export class LocalStorageService {
    storage = localStorage;
    /**
     *
     * @param key
     * @param value
     * @returns {any}
     */
    public setItem<T>(key: string, value: T): Observable<T> {
        this.storage.setItem(key, JSON.stringify(value));
        return of<T>(value);
    }

    /**
     *
     * @param key
     * @returns {any}
     */
    public getItem<T>(key: string): Observable<T> {
        const value = this.storage.getItem(key);
        return of<T>(
            value ? JSON.parse(this.storage.getItem(key)) : (null as T)
        );
    }

    /**
     *
     * @param key
     * @returns {any}
     */
    public removeItem(key: string): Observable<void> {
        return of(this.storage.removeItem(key));
    }
}
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { isString, isNumber, isDate } from 'lodash';
import { LocalStorageService } from './local-storage.service';
/**
 * Cache storage record interface
 */
interface CacheStorageRecord {
    expires: Date;
    value: any;
}

@Injectable({
    providedIn: 'root',
})
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).pipe(
            //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
            switchMap((val: CacheStorageRecord | null) => {
                console.warn('flatMap', val);
                if (val) {
                    return of(val.value);
                } else {
                    return observable.pipe(
                        switchMap((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);
        console.warn('value', value);
        return this.localstorage
            .setItem<CacheStorageRecord>(key, {
                expires: _expires,
                value: value,
            })
            .pipe(
                map((item) => {
                    return item.value;
                })
            );
    }

    /**
     *
     * @param key
     * @returns {Observable<null>}
     */
    expire(key: string): Observable<void> {
        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 as number) * 1000);
        }
        if (isString(expires)) {
            return new Date(expires);
        }
        if (isDate(expires)) {
            return expires as Date;
        }

        return new Date();
    }
}
@yudgine2

This comment has been minimized.

Copy link

@yudgine2 yudgine2 commented Apr 12, 2021

Thank you!!

@ranibb

This comment has been minimized.

Copy link

@ranibb ranibb commented Jun 25, 2021

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment