DEV Community

Michael Muscat
Michael Muscat

Posted on • Updated on

Render as you fetch with Angular lifecycle hooks

What if you could load asynchronous data right inside your component without the need for async pipes, subscribe, or even RxJS? Let's jump right in.

Fetching Data

To start with, we need some data to display in our app. We'll define a service that fetches some todos for us via HTTP.

const endpoint = "https://jsonplaceholder.typicode.com/todos"

@Injectable({ providedIn: "root" })
class TodosResource implements Fetchable<Todo[]> {
   fetch(userId: number) {
      return this.http.get(endpoint, {
         params: { userId }
      })
   }

   constructor(private http: HttpClient) {}
}
Enter fullscreen mode Exit fullscreen mode

The fetch function itself doesn't have to return an observable. It could just as easily return any observable input, such as a promise. We'll make all resources adhere to this Fetchable interface.

For now this is just a normal Angular service. We'll come back to it later.

The Resource Interface

At its core, a resource can do two things:

interface Resource<T extends Fetchable<any>> {
   fetch(...params: FetchParams<T>): any
   read(): FetchType<T> | undefined
}
Enter fullscreen mode Exit fullscreen mode

fetch Tell the resource to fetch some data. This could come from a HTTP or GraphQL endpoint, a websocket or any another async source.

read Attempt to read the current value of the resource, which might be undefined because no value has arrived yet.

With this interface defined we can write a class that implements it.

Implementation

The example below is truncated for the sake of brevity. A more concrete example can be found here

import { EMPTY, from, Subscription } from "rxjs"

export class ResourceImpl<T extends Fetchable> 
   implements Resource<T> {

   value?: FetchType<T>
   params: any
   subscription: Subscription
   state: string

   next(value: FetchType<T>) {
      this.value = value
      this.state = "active"
      this.changeDetectorRef.markForCheck()
   }

   read(): FetchType<T> | undefined {
      if (this.state === "initial") {
         this.connect()
      }
      return this.value
   }

   fetch(...params: FetchParams<T>) {
      this.params = params
      if (this.state !== "initial") {
         this.connect()
      }
   }

   connect() {
      const source = this.fetchable.fetch(...this.params)
      this.state = "pending"
      this.unsubscribe()
      this.subscription = from(source).subscribe(this)
   }

   unsubscribe() {
      this.subscription.unsubscribe()
   }

   constructor(
      private fetchable: T,
      private changeDetectorRef: ChangeDetectorRef
   ) {
      this.source = EMPTY
      this.subscription = Subscription.EMPTY
      this.state = "initial"
   }
}
Enter fullscreen mode Exit fullscreen mode

The resource delegates the actual data fetching logic to the fetchable object which is injected in the constructor. The resource will always return the latest value when it is read.

You'll also notice that we don't immediately fetch data if we are in an initial state. For the first fetch we wait until read is called. This is necessary to prevent unnecessary fetches when a component is first mounted.

Let's also write another service to help us manage our resources.

import { 
   Injectable, 
   InjectFlags, 
   Injector, 
   ChangeDetectorRef
} from "@angular/core"

@Injectable()
export class ResourceManager {
   private cache: Map<any, ResourceImpl<Fetchable>>

   get<T extends Fetchable>(token: Type<T>): Resource<T> {
      if (this.cache.has(token)) {
         return this.cache.get(token)!
      }
      const fetchable = this.injector.get(token)
      const changeDetectorRef = this.injector
         .get(ChangeDetectorRef, undefined, InjectFlags.Self)
      const resource = new ResourceImpl(
         fetchable, 
         changeDetectorRef
      )
      this.cache.set(token, resource)
      return resource
   }

   ngOnDestroy() {
      for (const resource of this.cache.values()) {
         resource.unsubscribe()
      }
   }

   constructor(private injector: Injector) {
      this.cache = new Map()
   }
}
Enter fullscreen mode Exit fullscreen mode

Usage

Now that we have built our resource services, let's see it in action!

<!-- todos.component.html -->

<div *ngFor="let todo of todos">
  <input type="checkbox" [value]="todo.complete" readonly />
  <span>{{ todo.title }}</span>
</div>

<button (click)="loadNextUser()">
  Next user
</button>
Enter fullscreen mode Exit fullscreen mode
import {
   Component,
   OnChanges,
   DoCheck,
   Input,
   ChangeDetectionStrategy
} from "@angular/core"

import { 
   Resource,
   ResourceManager
} from "./resource-manager.service"

import { Todos, TodosResource } from "./todos.resource"

@Component({
   selector: "todos",
   templateUrl: "./todos.component.html",
   providers: [ResourceManager],
   changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodosComponent implements OnChanges, DoCheck {
   @Input()
   userId: number

   resource: Resource<TodosResource>

   todos?: Todos[]

   ngOnChanges() {
      this.loadNextUser(this.userId)
   }

   ngDoCheck() {
      this.todos = this.resource.read()
   }

   loadNextUser(userId = this.userId++) {
      this.resource.fetch(userId)
   }

   constructor(manager: ResourceManager) {
      this.userId = 1
      this.resource = manager.get(TodosResource)
      this.resource.fetch(this.userId)
   }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can see that fetch is called twice; once in the constructor and again during the ngOnChanges lifecycle hook. That's why we need to wait for a read before subscribing to the data source for the first time.

All the magic happens in ngDoCheck. It's normally a bad idea to use this hook, but it's perfect for rendering as you fetch! The read function simply returns the current value of the resource and assigns it to todos. If the resource hasn't changed since the last read, it's a no-op.

If you're wondering why this works, scroll back to the next function in ResourceImpl.

next() {
   // ...
   this.changeDetectorRef.markForCheck()
}
Enter fullscreen mode Exit fullscreen mode

This marks the view dirty every time the resource receives a new value, and eventually triggers ngDoCheck. If a resource happens to be producing synchronous values very quickly we also avoid additional change detection calls. Neat!

Summary

We can render as we fetch by taking advantage of Angular's change detection mechanism. This makes it easy to load multiple data streams in parallel without blocking the view, and with a little more effort we can also show a nice fallback to the user while the data is loading. The approach is data agnostic and should complement your existing code.

Happy coding!

Top comments (0)