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) {}
}
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
}
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"
}
}
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()
}
}
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>
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)
}
}
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()
}
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)