Sekrab Garage

Storage and Caching

LocalStorage wrapper service in Angular

AngularDesign June 9, 22

Browser Storage is accessible in browser platform only, and it is just one way to cache in browser. Thus we should avoid accessing it directly in code, wrapping it with internal service allows us to control it, or even replace it. In this post, let us create a Storage Service in Angular, and organize it to better control what goes into localStorage.

The benefits we want to cover:

  • Protect direct access to a web class, that could change in the future
  • Allow different implementations. Here is a short read about Browser storage limits and eviction criteria, to get you dreaming about other solutions
  • Allow different server platform implementation
  • Unify naming for better debugging
  • Control expiration and force reset of cache

The service

The basic service is a wrapper of the window localStorage, looks too innocent, but we will build on top of it as we go along.

import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class StorageService {
  constructor() {}
  setItem(key: string, value: any) {
    // if json, stringify it
    localStorage.setItem(key, JSON.stringify(value));
  }
  getItem(key: string): any {
    // detect and parse json later
    return localStorage.getItem(key);
  }
  removeItem(key: string) {
    localStorage.removeItem(key);
  }
  clear(): void {
    localStorage.clear();
  }
}

Usage looks like this

// usage of service injected
this.storage.getItem('profile');

// setting
this.storage.setItem('profile', {name: 'name', email: 'email'});

Protecting localStorage

The first enhancement is isolating the window localStorage into a private mamber, this allows for future plans to use other than localStorage, like IndexDB or Cache API, or disallow the server platform.

// slight enhancement, use ourStorage instead of directly referring to localStorage
export class StorageService {
  constructor() {}
  
  private get ourStorage(): Storage {
    return localStorage;
  }

  setItem(key: string, value: any) {
    // if json, stringify it
    this.ourStorage.setItem(key, JSON.stringify(value));
  }
  getItem(key: string): any {
    // this will detect and parse json later
    return this.ourStorage.getItem(key);
  }
  removeItem(key: string) {
    this.ourStorage.removeItem(key);
  }
  clear(): void {
    this.ourStorage.clear();
  }
}

Modeling

To model properly, let me list the enhancements we want to add:

  • Key prefix
    So that we can spot them faster, and clear them easier. Third party plugins might also be dumping stuff in localStorage, using their own interfaces, we do not want to get lost in the mix.
  • Expiration
    We want our localStorage to expire, with custom timeouts
  • Force reset
    When a new deployment has breaking changes, we need to force production to clear out
  • Multi lingual caching
    Some data will be language-specific, we want the key to hold the language code as well

The final result of the storage would look like this


// cached data, for specific language, prefixed with "garage", and expiration added
garage.en.categories = {
  timestamp: 1653973659116, // when it was saved
  expiresin: 1, // number of hours
  value: [ // the value saved
      {key: 'categoryname', id:'232432423', value: 'Category Name English'}
    ]
}

// some data are not language specific
garage.user = {
  timestamp: 1653973659116,
  expiresin: 168, // a week
  value: {
    name: 'username',
    email: 'email...'
  }
}

// reset key with unique key, if exists, do not reset
garage.20220607 = true

The model of our storage now is clear, so let's create a model for that, and rewrite the service to take those into account

// storage model
interface IStorage {
  value: any;
  expiresin: number;
  timestamp: number;
}

Key prefix

In order to prefix with the proper cache key, and differentiate between "cached" multilingual data, versus plain storage, the cleanest way is to create dedicated methods for language-sensitive items. Let's call them "Cache" methods.

Cache API and IndexDB are ideal solutions for multilingual scenarios, so the naming is not too far off

For unilingual apps, we can use the app's language, for example "en."


// storage service, change setItem to accept withLanguage optional param
setItem(key: string, value: any, withLanguage = false) {
  // setup the key, 
  // TODO: get prefix and language from configurations
  const _key = `${storageKey}${withLanguage ? '.' + configLanguage : ''}.${key}`;

  // objects must be strings
  this.ourStorage.setItem(_key, JSON.stringify(_value));
}
getItem(key: string, withLanguage = false): any {
   // setup the key
  const _key = `${storageKey}${withLanguage ? '.' + configLanguage : ''}.${key}`;

  // TODO: JSON parse
  return this.ourStorage.getItem(_key);
}
removeItem(key: string, withLanguage = false) {
  // setup the key
  const _key = `${storageKey}${withLanguage ? '.' + configLanguage : ''}.${key}`;

  this.ourStorage.removeItem(_key);
}
// for caching language specific, prefix with language
setCache(key: string, value: any) {
  this.setItem(key, value, true);
}
getCache(key: string): any {
  return this.getItem(key, true);
}
removeCache(key:string){
  this.removeItem(key, true);
}

Wait, we can enhance this a bit by creating a private method to get key:

private getKey(key: string, withLanguage = false): string {
  // one function to take care of key
  return `${storageKey}${withLanguage ? '.' + configLanguage : ''}.${key}`;
}

Expiration

Depending on different scenarios, most cache needs to live forever, but sometimes, a value changes on a daily basis (for example, currency exchange), it would be better if we could clear the cache to force fresh data from the server. The idea is: save timestamp and expiration value, on get, check if item is expired, if so, remove it, else return it.

The default timeout should be fed from configurations, instead of assumed at infinity, because in some apps it is not ideal to keep the cache forever, this is a good balance between fresh data, and user experience.

// Change set and get to account for expiration
// TODO: add configTimeOut to configuration
setItem(key: string,value: any, expiresin: number = configTimeOut, withLanguage = true) {
  // prepare value
  const _value: IStorage = {
    value,
    timestamp: Date.now(), // in milliseconds
    expiresin: expiresin, // in hours
  };

  // objects must be strings
  this.ourStorage.setItem(
    this.getKey(key, withLanguage),
    JSON.stringify(_value)
  );
}

getItem(key: string, withLanguage = false): any {
  // check value
  const _key = this.getKey(key, withLanguage);
  const value: any = this.ourStorage.getItem(_key);

  if (!value) {
    return null;
  }
  // cast
  const _value: IStorage = JSON.parse(value);

  // calculate expiration, expiresin is in hours, so convert to milliseconds
  if (Date.now() - _value.timestamp > _value.expiresin * 3_600_000) {
    // if expired, remove
    this.ourStorage.removeItem(_key);
    return null;
  }
  // return the value
  return _value.value;
}

setCache(key: string, value: any, expiresIn: number = configTimeOut) {
  this.setItem(key, value, expiresIn, true);
}

Here is a quick test on StackBlitz

Localstorage

Force reset

To be able to force a reset, a key should be set in configuration, whether in environment or external configurations. On initialization of our service, find the item in localStorage, if not found, clear storage and start over.

First, let's create our configuration and place all related items in it:

// Config.ts, could be fed directly from environment, or external configuration
export const Config = {
  Basic: {
    language: 'en'
  },
  Storage: {
    Key: 'garage',
    Timeout: 168, // a week
    ResetKey: '20220607' // yyyymmdd is best option
  },
};

When localStorage service is first injected, this method is called:

// update localstorage service
export class StorageService {
  constructor() {
    // find out if storage needs to be forcefully nulled
    this._setResetKey();
  }

  private _setResetKey(): void {
    const _key = this.getKey(Config.Storage.ResetKey);
    const _reset: any = this.ourStorage.getItem(_key);

    // if it does not exist, it must have changed in config, remove everything
    if (!_reset || _reset !== 'true') {
      this.clear();
      // set a new one
      this.ourStorage.setItem(_key, 'true');
    }
  }
  // ...

  clear(): void {
    // remove all prefixed items
    const toClear = [];

    for (let i = 0; i < this.ourStorage.length; i++) {
      const name = this.ourStorage.key(i);
      if (name.indexOf(Config.Storage.Key) === 0) {
        // delay because removeItem is destructive
        toClear.push(name);
      }
    }

    toClear.forEach((n) => this.ourStorage.removeItem(n));
  }
}

A word about routing module, external configurations and storage sequence of events

A while back we created an external configuration service, and investigated the sequence of events in the most extreme case for Router Initialization. In that case, the router loaded faster than the configuration. And we concluded that in order to avoid mishaps, we should subscribe to the config$ observable. But, here is an interesting twist: the StorageService is injected first, calls the constructor immediately before the configuration is ready, and sets the initial reset key. The side effect of using localStorage in a "resolver" service is a possible unintended reset of storage. To avoid that, the StorageService constructor must wait for configurations:

// in storageService reset key, adjust to wait for configurations

private _setResetKey(): void {

 // wait for config to be loaded first
  this.configService.config$.pipe(
      first(config => config.isServed),
  ).subscribe(config => {
    // even with error, resume as above
   
    // ...
}

Data caching service

One of the two most popular uses of caching in client side, is caching data that does not change often.

This is not the same as "caching API responses," so don't get mixed up.

For example: courtesy titles, product categories, country names, vocations... etc. The result of using in component should be agnostic, like this:

this.cats$ = this.dataService.GetCategories();

The data service then, should take care of caching in and out:

@Injectable({ providedIn: 'root' })
export class DataService {
  // this is usually a public api point
  private _listUrl = '/data/categories';

  constructor(private _http: HttpClient, private storageService: StorageService) {}

  GetCategories(): Observable<any> {
    const _url = this._listUrl;

    // TODO:
    // find in cache first
    // if found, return of(data)
    // if not make an http call
    return this._http.getCategories().pipe(
      map((response) => {
        // TODO:
        // map response to internal model then save in cache
        return response;
      })
    );
  }
}

I would like to be able to reuse that for multiple things, like countries and cities

GetCountries(): Observable<any> {
  // same as above
}

GetCities(id: string): Observable<any> {
  // same as above but for a single country id
}

First, if we can manage it, let's create a Data model. If we cannot control the response, we will create individual mappers. (This is a good habit to make sure your UI components are decoupled from API returned model.)

// data model
export interface IData {
  value: string;
  id: string;
  key: string;
}

// general mapper for data
export class DataClass {
  // map to IData
  public static NewInstance(data: any): IData {
    if (!data) {
      return null;
    }
    return {
      value: data.value,
      id: data.id,
      key: data.key,
    };
  }
  public static NewInstances(data: any[]): IData[] {
    return data.map(DataClass.NewInstance);
  }
}

Then let's write a single private method: GetData

export class DataService {
  
  // the urls to use
  private _catsUrl = '/data/categories';
  private _countriesUrl = '/data/countries';
  // notice that this will be associated with id
  private _citiesUrl = '/data/cities/:id'; 

  constructor(
    private _http: HttpService,
    private storageService: StorageService
  ) {}

  private GetData(type: string, url: string, id?: string): Observable<IData[]> {
    // find in Cache by type
    const _data = this.storageService.getCache(type);

    if (_data) {
      return of(_data);
    } else {
      // get from http
      return this._http.get(url).pipe(
        map((response) => {
          // map response to internal model then save in cache
          let _retdata = DataClass.NewInstances(<any>response);

          // assign to localstorage with key and expires in hours if set
          // TODO: enhance this line with controlled expiration, and ids
          this.storageService.setCache(type, _retdata);

          return _retdata;
        })
      );
    }
  }

  GetCategories(): Observable<IData[]> {
    return this.GetData('categories', this._catsUrl);
  }

  GetCountries(): Observable<IData[]> {
    return this.GetData('countries', this._countriesUrl);
  }

  GetCities(id: string): Observable<IData[]> {
    return this.GetData('cities', this._citiesUrl, id);
  }
}

Cached by id

Cities retrieved are by country id, as an example to id-sensitive data. Which means every time user requests a list of cities by a certain country, we need to cache by id. The solution is to append the "id" to the key of the cached item.

// change DataService to pass id in key
export class DataService {
  // ...
  // id = 0 by default
  private GetData(type: string, url: string, id: string = '0'): Observable<IData[]> {
    
    // append id to key
    const _data = this.storageService.getCache(`${type}.${id}`);
    // ...
        // append id to key
        this.storageService.setCache(`${type}.${id}`, _retdata);
    // ...
    }
  }
}

In our component, now that looks like this

ngOnInit(): void {
  this.cats$ = this.dataService.GetCategories();
  this.countries$ = this.dataService.GetCountries();

  // mimic get cities per country, each country will save a different set
  this.cities$ = this.dataService.GetCities('12');
}

Our storage now looks like this:

LocalStorage

Custom expiration, and Enums

It makes sense that some data need to live longer than other data, for example exchange rates need not be cached for longer than 24 hours. Let's also keep the urls in a Map, and use Enums for the types, for better typing.

// add enum
export enum EnumDataType {
  Country,
  City,
  Category,
  ExchangeRates
}

export class DataService {
  private _catsUrl = '/data/categories';
  private _countriesUrl = '/data/countries';
  private _citiesUrl = '/data/cities/:id';
  private _ratesUrl = '/data/xe';

  // lets group our cached urls in a Map, and use an enum for the keys
  // and add an optional timeout
  private cacheUrls = new Map<EnumDataType,{url:string,expiresin?:number}>();

  constructor(
    private _http: HttpService,
    private storageService: StorageService
  ) {
    // set the urls
    this.cacheUrls.set(EnumDataType.Category, {url: this._catsUrl});
    this.cacheUrls.set(EnumDataType.City, {url: this._citiesUrl});
    // countries probably should never expire
    this.cacheUrls.set(EnumDataType.Country, {url: this._countriesUrl, expiresin: 8000});
    
    // exchange rates should expire in 24 hours
    this.cacheUrls.set(EnumDataType.ExchangeRates, {url: this._ratesUrl, expiresin: 24});
  }

  private GetData(type: EnumDataType, id: string = '0'): Observable<IData[]> {
    // find in Cache by type
    const _cacheUrl = this.cacheUrls.get(type);

    // get data from map, and replace id if found, by id passed if any 
    const url = _cacheUrl.url.replace(':id', id);

    // change type enum into a string
    const key: string = EnumDataType[type];
    const data = this.storageService.getCache(`${key}.${id}`);

    if (data) {
      // BONUS: use the debug operator to log out the result, why not? read below
      return of(data).pipe(debug('GetCache ' + key));
    } else {
      // get from http
      return this._http.get(url).pipe(
        map((response) => {
          // map response to internal model then save in cache
          let _retdata = DataClass.NewInstances(<any>response);
          
          // here, since we have enums, we can make a switch statement
          // to select different mappers

          // assign to localstorage with key and expires in hours if set
          this.storageService.setCache(`${key}.${id}`, _retdata, _cacheUrl.expiresin);

          return _retdata;
        })
      );
    }
  }

  GetCategories(): Observable<IData[]> {
    return this.GetData(EnumDataType.Category);
  }

  GetCountries(): Observable<IData[]> {
    return this.GetData(EnumDataType.Country);
  }

  GetCities(id: string): Observable<IData[]> {
    return this.GetData(EnumDataType.City, id);
  }
  GetRates(): Observable<IData[]> {
    return this.GetData(EnumDataType.ExchangeRates);
  }
}

The debug operator added is the one we created previously in Catching and handling errors in Angular.

Updating an item in cache means we need to remove cache first, to let the service get a fresh copy.

storageService.remove(EnumDataType[EnumDataType.Categories])

But let's rant a bit about this.

Ranting

I have seen more sophisticated localStorage implementations on the web, Here are some of the ideas I spotted, and my take on them

  • Retrieve hints from API, flagging keys that need to be flushed. This makes forcing an update pretty selective, and puts the control in the hands of the API developers. For small and medium sized Apps, it is an overkill.
  • Auto bump reset keys on build: a task that bumps the reset key on every build, and thus assuring fresh data on next deployment. This might be feasible for small apps, but as the app gets larger, do you really want to nullify your cache on every deployment?
  • Integrating service workers to save offline: though implementing it removes the simplicity of localStorage, this was the most interesting idea I faced. One warm Tuesday.
  • Create observables for Storage data: terrible idea, it involves exposing the StorageService itself, and dealing with it as a state store. The app should not react to changes in Storage, Storage should react to clear instructions.

So back to our "updating an item in cache data" statement above. If you ever find yourself having to update cached data as a result of user interaction, you might be in the wrong place, state management might be what you need instead. Updating user information, updates storage after going through API, and state change. Updating category on the other hand is trickier. Because "category" is shared with other users. LocalStorage is not the right place to keep them.

Server platform

In an Express server that hosts the application in server platform, we only need to provide global functions for localStorage.

 // this is the server.js or server.ts that listens to the express app hosting the angular app
global.localStorage = {
  getItem: function (key) {
    return null;
  },
  setItem: function (key, value) {},
  clear: function () {},
  removeItem: function (key) {},
  length: 0
};

But that has a side effect on data requests. I will put some time next week to figure out a solution, if there is a solution.😴

Thank you for reading this far. Let me know if batteries were not included.