DEV Community

Umar Mohammed
Umar Mohammed

Posted on

How to reuse queries in Angular Query

Having used Tanstack Query in the past I was excited to see an official adapter for Angular.

Given a basic application with the following files

// app-routing.module.ts
const routes: Routes = [
  {
    path: '',
    component: HeroListComponent,
  },
  {
    path: ':heroId',
    component: HeroDetailsComponent,
  },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      bindToComponentInputs: true,
    }),
  ],
  exports: [RouterModule],
})
export class AppRoutingModule {}

// hero.service.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Hero } from './hero';
import { tap } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class HeroService {
  http = inject(HttpClient);

  heroes$ = this.http
    .get<Hero[]>('/api/heroes')
    .pipe(
      tap(() => console.log(`GET /api/heroes ${new Date().toISOString()}`))
    );

  getHero(id: number) {
    return this.http
      .get<Hero>(`/api/heroes/${id}`)
      .pipe(
        tap(() =>
          console.log(`GET /api/heroes/${id} ${new Date().toISOString()}`)
        )
      );
  }
}

//hero-list.component.ts
import { Component, inject } from '@angular/core';
import { HeroService } from './hero.service';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { lastValueFrom } from 'rxjs';

@Component({
  template: `<h2>Hero List</h2>
    @if (query.isPending()) { Loading... } @if (query.error()) { An error has
    occurred: {{ query.error()?.message }}
    } @if (query.data(); as data) {
    <ul>
      @for (hero of data; track $index) {
      <div>
        <a [routerLink]="[hero.id]">{{ hero.name }}</a>
      </div>
      }
    </ul>
    } `,
})
export class HeroListComponent {
  heroService = inject(HeroService);

  query = injectQuery(() => ({
    queryKey: ['heroes'],
    queryFn: () => lastValueFrom(this.heroService.heroes$),
  }));
}

// hero-details.component.ts
import { Component, inject, input, numberAttribute } from '@angular/core';
import { HeroService } from './hero.service';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { lastValueFrom } from 'rxjs';

@Component({
  template: `<h2>Hero Detail</h2>
    @if (query.isPending()) { Loading... } @if (query.error()) { An error has
    occurred: {{ query.error()?.message }}
    } @if (query.data(); as data) {
    <div>
      {{ data.name }}
    </div>
    } `,
})
export class HeroDetailsComponent {
  heroService = inject(HeroService);

  heroId = input.required({ transform: numberAttribute });

  query = injectQuery(() => ({
    queryKey: ['heroes', this.heroId()],
    queryFn: () => lastValueFrom(this.heroService.getHero(this.heroId())),
  }));
}
Enter fullscreen mode Exit fullscreen mode

I wanted to use the techniques described in https://dev.to/this-is-angular/this-is-your-signal-to-try-tanstack-query-angular-35m9 to see how to reuse queries, particularly ones which depend on router params.

Creating the custom injection function and extracting out the heroQuery

import {
  assertInInjectionContext,
  inject,
  Injector,
  runInInjectionContext,
} from '@angular/core';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { HeroService } from './hero.service';
import { lastValueFrom } from 'rxjs';

export const createQuery = <T, U>(
  query: (params: T) => U,
  params: T,
  { injector }: { injector?: Injector } = {}
) => {
  injector = assertInjector(createQuery, injector);
  return runInInjectionContext(injector, () => {
    return query(params);
  });
};

export function assertInjector(fn: Function, injector?: Injector): Injector {
  // we only call assertInInjectionContext if there is no custom injector
  !injector && assertInInjectionContext(fn);
  // we return the custom injector OR try get the default Injector
  return injector ?? inject(Injector);
}

export function heroQuery({ heroId }: { heroId: number }) {
  const solutionApi = inject(HeroService);
  return injectQuery(() => ({
    queryKey: ['heroes', heroId],
    queryFn: () => lastValueFrom(solutionApi.getHero(heroId)),
  }));
}
Enter fullscreen mode Exit fullscreen mode

I can then use this in the hero-details component with the following changes:

@Component({
  template: `<h2>Hero Detail</h2>
    @if(query; as query) { @if (query.isPending()) { Loading... } @if
    (query.error()) { An error has occurred: {{ query.error()?.message }}
    } @if (query.data(); as data) {
    <div>
      {{ data.name }}
    </div>
    } } `,
})
export class HeroDetailsComponent implements OnInit {
  heroId = input.required({ transform: numberAttribute });

  injector = inject(Injector);

  query: ReturnType<typeof heroQuery> | null = null;

  ngOnInit() {
    this.query = createQuery(
      heroQuery,
      {
        heroId: this.heroId(),
      },
      { injector: this.injector }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

However this is not as nice as the original code, we have to split the query between the component's constructor and ngOnit lifecycle hook. We also need an extra if in the component since we need to check if the query is null.

We can wrap the createQuery to tidy up the interface a bit

export const queryCreator = <T, U>(
  query: (params: T) => U,
  params: () => T,
  { injector }: { injector?: Injector } = {}
) => {
  return {
    query: null as U | null,
    init: function () {
      if (this.query) {
        return;
      }
      this.query = createQuery(query, params(), { injector });
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

and update the hero-component to

@Component({
  template: `<h2>Hero Detail</h2>
    @if(creator.query; as query) { @if (query.isPending()) { Loading... } @if
    (query.error()) { An error has occurred: {{ query.error()?.message }}
    } @if (query.data(); as data) {
    <div>
      {{ data.name }}
    </div>
    } }`,
})
export class HeroDetailsComponent {
  heroId = input.required({ transform: numberAttribute });

  injector = inject(Injector);

  creator = queryCreator(
    heroQuery,
    () => ({
      heroId: this.heroId(),
    }),
    { injector: this.injector }
  );

  ngOnInit() {
    this.creator.init();
  }
}
Enter fullscreen mode Exit fullscreen mode

However its still not great and is starting to feel like overkill at this point. Please leave a comment below if you can point me in the right direction.

Link to source code https://github.com/umar-hai/reuse-queries-demo

Top comments (0)