Displaying cacheable content with a partial component in Angular

ayyash

Ayyash

Posted on September 12, 2023

Displaying cacheable content with a partial component in Angular

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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(...);
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
        // ...
    },
    // ...
]
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 keyid, 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" ...>
`
Enter fullscreen mode Exit fullscreen mode

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' ...>
`
Enter fullscreen mode Exit fullscreen mode

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) {}
}
Enter fullscreen mode Exit fullscreen mode

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 to Default instead of OnPush, or injecting ChangeDetectorRef and explicitly calling detectChanges(). But this is another Tuesday.

@Input() set key(value: string) {
  this.data$ = this._dataService.GetSingleDataByKey(this.type, value);
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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 = () => {
    // ...
  }

Enter fullscreen mode Exit fullscreen mode

That's it, that's enough. Thanks for reading this far though.

RESOURCES

RELATED POSTS

💖 💪 🙅 🚩
ayyash
Ayyash

Posted on September 12, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related