Ayyash
Posted on September 12, 2023
After creating a localstroage wrapper, and a data cache decorator, let's put them together to create a data service that handles all cacheable data. Also, let's create a a component that displays the cacheable content based on a specific target.
Here is a quick summary of what we previously accomplished
LocalStorage wrapper service in Angular
We created a window.localStorage
wrapper in Angular to control the data modeling of what gets saved in localStorage
, the data model we came to was the following
interface IStorage {
value: any;
expiresin: number;
timestamp: number;
}
Then we created a data service specifically to save recurring data with specific data model, and cache it instead of querying the server every time.
export interface IData {
value: string;
id: string;
key: string;
}
So we ended up with a line like this
this.countries$ = this.dataService.GetCountries();
That created entries like this
garage.en.countries.0
A cache decorator in Angular
Then we created a decorator to save any http
call with any data model in the localStorage
. So we had lines like this
@DataCache()
GetCountries(options) {
return http.get(...);
}
Today, we will use the decorator for the data service, and go beyond that to create a partial component that understands it should display the value, of a specific data key.
An example of Foods
Say the cacheable content is food group, the groups are known: "vegetables, milk, fruits, grain". The value to be displayed (could be multilingual comes from a unique point in an API, let's assume it is like this:
{
"id": "32424234",
"key": "fruits",
"value": "Fruits"
}
Let's not bring in all languages, only one language really matters for the user. So the API should handle this properly by reading Accept-Language
header.
Then let's assume an API call (e.g. /api/foods
) returns a list of items, one of properties is group
[
{
"id": "344234",
"name": "Breakfast",
"group": "fruits"
// ...
},
// ...
]
I want to be able to display the value
by fetching the correct key
like this
<ul>
<li *ngFor="let food in foods">
{{ food.name }} <span>{{ getValue(food.group) }}
<span>
<li>
</ul>
The target, is to figure out what this getValue
really is, let's begin with the service itself.
The service
I will continue where we left off in our previous data caching hunt in StackBlitz. We will create a new API call for food groups. To get groups, we need to implement this function
this.groups$ = this.dataService.GetGroups();
So in our data service, we will create a new GetGroups
method, and we will also adapt the service to use the decorator instead. (Notice how we use the argument withArgs
to pass an id
to the entry key.)
// new data service (services/data.service)
// now that we are using data decorator, we can create a new function for every data group
export class DataService {
constructor(
private _http: HttpService,
// make this public to use with decorator
public storageService: StorageService
) {}
@DataCache({ key: 'Countries', expiresin: 8000 })
GetCountries(): Observable<IData[]> {
return this._http.get(this._countriesUrl).pipe(
map((response: any) => {
return DataClass.NewInstances(<any>response);
})
);
}
// if we need an id, we just pass withArgs
@DataCache({ key: 'Cities', withArgs: true })
GetCities(id: string): Observable<IData[]> {
return this._http.get(this._citiesUrl.replace(':id', id)).pipe(
map((response: any) => {
return DataClass.NewInstances(<any>response);
})
);
}
// our new groups here
@DataCache({ key: 'Groups' })
GetGroups(): Observable<IData[]> {
return this._http.get(this._groupsUrl).pipe(
map((response: any) => {
return DataClass.NewInstances(<any>response);
})
);
}
}
So far we recreated what we already have in data service. The use of which did not change a bit in our component. Now we are going to focus on groups.
// This still acts the same way, gets countries and caches them
this.groups$ = this.dataService.GetGroups();
Get single food by key
My goal is to end up with a partial in which I pass the key
, and the type
(identifier), to get the value
from the cache. If cache is not initiated yet, it should initiate it and save it.
<cr-data type="Groups" key="milk"></cr-data>
We expect the API to return the key
of the group, instead of the whole package of key
, id
, and value
.
Type versus Enum
We previously used Enums
and it was fine, except that in our case if we are to use an Enum
, we must get reference to it in our template code, like this:
// in data model
export enum EnumDataType {
Groups,
// ...
}
// in component template code
enumDataType = EnumDataType;
// then in template
`
<cr-data [type]="enumDataType.Groups" ...>
`
What I found out is that Types
in typescript
are easier to work with. They validate, and they don't need to be brought in to component.
// in data model
export type TypeDataType = 'Country' | 'Category' | 'Rate' | 'City' | 'Groups';
// then in template
`
<cr-data type='Groups' ...>
`
This is much cleaner. Why Groups
and not Group
? Coming right up.
Data partial component
Let's create the code for the partial component, we need two inputs at least (we can combine them into an object but it doesn't really itch that much). We also need to inject the dataService
. The template is simply an async
observable
that displays the value
when the data is available.
// data.partial
@Component({
selector: 'cr-data',
template: `{{ (data$ | async)?.value }}`,
//...
})
export class DataPartialComponent {
// an observable
data$: Observable<IData | undefined>;
// we need key, and type
@Input() key: string;
@Input() type: TypeDataType;
// inject the service
constructor(private _dataService: DataService) {}
}
Now whenever the key
is set, we need to get data by key
and type
. We can do that AfterViewInit
or OnInit
, but a setter
is more ready for change detection.
Note, a setter alone won't guarantee that the property will change, to implement proper change detection mechanism, other strategies are needed, like changing the value
changeDetection
toDefault
instead ofOnPush
, or injectingChangeDetectorRef
and explicitly callingdetectChanges
(). But this is another Tuesday.
@Input() set key(value: string) {
this.data$ = this._dataService.GetSingleDataByKey(this.type, value);
}
Right. Let's implement the GetSingleDataBykey
.
GetSingleDataByKey
So back to our data service, let's create a generic get item by key. We have the type
, and we are going to use it to construct a method name and call it. In JavaScript
, calling a method in a class is as simple as this:
className[sometVar]();
Let's also check if the function exists before we call it. Because we are using types, we can add extra checks to align the type Country
with GetCountries
, but today, I am happy with using Countries
as the type all the way. So that is why I chose Groups
instead of Group
.
GetSingleDataByKey(type: TypeDataType, key: string): Observable<IData | undefined> {
// WATCH: observable of null
if (key === null) {
return of(undefined);
}
// use the type to find GetSomething() then call it.
const getFunction = this['Get'+type];
if (!getFunction) {
return of(undefined);
}
return getFunction.call(this).pipe(
// find element with the same key
map((data: IData[]) => data.find(n => n.key === key)));
}
// changed types to be aligned with method names in services/data.model
export type TypeDataType = 'Countries' | 'Categories' | 'Rates' | 'Cities' | 'Groups';
Putting this to test:
Groups names:
<cr-data type="Groups" key="fruits"></cr-data>.
<cr-data type="Groups" key="milk"></cr-data>.
<cr-data type="Groups" key="vegetables"></cr-data>.
// Displays:
Groups names: Fruits. Milk. Vegetables.
PS. keep cleaning the
localStorage
before testing, this one always drives me crazy.
Ranting: pipes
To have a pipe we must mimic the function of an async
pipe and build on it. Since we need to subscribe, I'd rather save the data locally in the pipe until next time. Here is the thing, in Angular documentation you can read those two lines:
- Each binding gets its own pipe instance.
- Each pipe instance caches its own URL and data and calls the server only once.
This sounds better than it really means. It does in no way mean the data is cached in client throughout the application session, nor does it mean that multiple pipes on the same page get to use the cached version, and no, it also does not mean that the same props will trigger a reuse of the cached data. It just means that on the same route, for the same pipe usage, when change detection take place, if the properties did not change, the cached data will be returned.
PS: pure pipes by definition do that out of the box, but this is an impure
pipe that must be told to do so.
Verdict: Pipes are smelly. Partial components are good for the job.
Decorator enhancement
Going back to our decorator, there is a slight enhancement we can introduce. When there is a list of items that will use the cached data concurrently, the API call will be made multiple times.
How to prevent concurrent calls when the data in storage is not set yet? Well, we can be sophisticated, and we can be really simple. The simple solution is to add a lock, and timeout
the call if the lock is true. The second call, is between 50% to 100% cached. 50% if you have a slow API, 100% if you have an adequate timeout
. We don't want to complicate things, so it should do.
// services/data.decorator
// enhancement to prevent concurrent calls
// add a basket of locks
const locks: { [key: string]: boolean } = {};
export function DataCache<T extends IStorageService>(options?: Partial<ICached>) {
return function (target: T, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
const cacheKey = options?.key || `${target.constructor.name}.${propertyKey}`;
descriptor.value = function (...args: any[]): Observable<any> {
const key = options?.withArgs ? `${cacheKey}_${JSON.stringify(args)}` : cacheKey;
// wrap it in a fireOff function
const fireOff = () => {
const _data: any = this.storageService.getItem(key);
if (_data) {
return of(_data).pipe(debug('Cached ' + cacheKey));
} else {
// lock first by adding a new entry
locks[cacheKey] = true;
return originalMethod.apply(this, args).pipe(
tap(response => {
this.storageService.setItem(key, response, options?.expiresin);
}),
finalize(() => {
// unlock by removing
delete locks[cacheKey];
})
);
}
};
// check if locked, wait some miliseconds and fireOff anyway (no second check)
// the timeout can be configurable and depends on project needs
return locks[cacheKey] ? timer(500).pipe(switchMap(fireOff)) : fireOff();
};
return descriptor;
};
}
We can keep the timeout
value in a configuration file, or better yet pass it as an option. I find this to be quite project-specific, and an abstraction I do not wish to add to the basic seed. So we'll leave it at that.
Now watching the network tab in a page that has a list of items that call the data service, the first call is an API call, all the rest are* local fetching. (Cannot showcase that in StackBlitz because there is no real http
call, but a simple console log in the mock data file, will show that the groups are fetched once). This might produce a nasty effect of slower label display*, it feels like a nasty cloud provider with baggage and things to do 😎. No seriously, the effect is acceptable and does not hinder experience. To get rid of it however, we just need to check the cache first and return in case anything exists.
// services/data.decorator
// ...
// check data first
const _data: any = this.storageService.getItem(key);
if (_data) {
// if localStroage exist, return
return of(_data);
}
// then fireoff
const fireOff = () => {
// ...
}
That's it, that's enough. Thanks for reading this far though.
RESOURCES
RELATED POSTS
Posted on September 12, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.