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
anchorThe 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'});
anchorProtecting 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();
}
}
anchorModeling
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 inlocalStorage
, 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;
}
anchorKey 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 andIndexDB
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}`;
}
anchorExpiration
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
anchorForce 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
// ...
}
anchorData 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);
}
}
anchorCached 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:
anchorCustom 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.
anchorRanting
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 theStorageService
itself, and dealing with it as a state store. The app should not react to changes inStorage
,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.
anchorServer 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.