Skip to content

Resource API Refactor

Just like with the Quicklists application, we are now going to make some modifications to this application to utilise resource and linkedSignal.

This refactor will be a lot more advanced, and also perhaps a bit more open to interpretation as to whether it’s actually an improvement or not.

This application has made heavy use of RxJS. It’s an application that has a lot of asynchronous stuff happening, even streaming results from the Reddit API and having them appear as each batch comes in, and this is a task that RxJS is typically quite suited for.

In fact, when I first attempted to refactor this application I did it mostly for experimentation purposes and seeing how far I could push resource and linkedSignal. I didn’t expect the result to be actually good.

But it was (at least in my opinion). Again, I don’t think there is a clear cut winner here, but on the balance I prefer the refactored approach that we are about to implement.

Refactoring the RedditService

I think the best way to approach this will be to just show the completely refactored service from the start, and then we will talk through how each bit works.

The good thing is that conceptually the flow of data and how things work is basically the same as what we have already discussed with RxJS, it’s just the mechanisms we are achieving to use it are a bit different.

import { Injectable, inject, linkedSignal } from '@angular/core';
import { rxResource, toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { Gif, RedditPost, RedditResponse } from '../interfaces';
import { FormControl } from '@angular/forms';
import { EMPTY } from 'rxjs';
import {
reduce,
debounceTime,
distinctUntilChanged,
expand,
map,
startWith,
} from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class RedditService {
private http = inject(HttpClient);
private gifsPerPage = 5;
subredditFormControl = new FormControl();
//sources
private subredditChanged$ = this.subredditFormControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
startWith('gifs'),
map((subreddit) => (subreddit.length ? subreddit : 'gifs')),
);
subreddit = toSignal(this.subredditChanged$);
paginateAfter = linkedSignal({
source: this.subreddit,
computation: () => null as string | null,
});
gifsLoaded = rxResource({
params: () => ({
subreddit: this.subreddit(),
paginateAfter: this.paginateAfter(),
}),
stream: ({ params }) =>
this.fetchRecursivelyFromReddit(params.subreddit, params.paginateAfter),
});
gifs = linkedSignal<ReturnType<typeof this.gifsLoaded.value>, Gif[]>({
source: this.gifsLoaded.value,
computation: (source, prev) => {
// initial and page loads
if (typeof source === 'undefined') return prev?.value ?? [];
// clear on subreddit change
if (
!prev ||
!prev.value[0]?.permalink.startsWith(`/r/${source.subreddit}`)
)
return source.gifs;
// accumulate values on paginate
return [...prev.value, ...source.gifs];
},
});
private fetchFromReddit(
subreddit: string,
after: string | null,
gifsRequired: number,
) {
return this.http
.get<RedditResponse>(
`https://www.reddit.com/r/${subreddit}/hot/.json?limit=100` +
(after ? `&after=${after}` : ''),
)
.pipe(
map((response) => {
const posts = response.data.children;
let gifs = this.convertRedditPostsToGifs(posts);
let paginateAfter = posts.length
? posts[posts.length - 1].data.name
: null;
return {
gifs,
gifsRequired,
paginateAfter,
subreddit,
};
}),
);
}
private fetchRecursivelyFromReddit(
subreddit: string,
paginateAfter: string | null,
) {
return this.fetchFromReddit(
subreddit,
paginateAfter,
this.gifsPerPage,
).pipe(
// A single request might not give us enough valid gifs for a
// full page, as not every post is a valid gif
// Keep fetching more data until we do have enough for a page
expand((response, index) => {
const { gifs, gifsRequired, paginateAfter } = response;
const remainingGifsToFetch = gifsRequired - gifs.length;
const maxAttempts = 5;
const shouldKeepTrying =
remainingGifsToFetch > 0 &&
index < maxAttempts &&
paginateAfter !== null;
return shouldKeepTrying
? this.fetchFromReddit(subreddit, paginateAfter, remainingGifsToFetch)
: EMPTY;
}),
map((response) => {
const { gifs, gifsRequired } = response;
const remainingGifsToFetch = gifsRequired - gifs.length;
if (remainingGifsToFetch < 0) {
// trim to page size
const trimmedGifs = response.gifs.slice(0, remainingGifsToFetch);
return {
...response,
gifs: trimmedGifs,
paginateAfter: trimmedGifs[trimmedGifs.length - 1].name,
};
}
return response;
}),
reduce(
(acc, curr) => ({
...curr,
gifs: [...acc.gifs, ...curr.gifs],
}),
{
gifs: [] as Gif[],
paginateAfter: null as string | null,
gifsRequired: this.gifsPerPage,
subreddit: 'gifs',
},
),
);
}
private convertRedditPostsToGifs(posts: RedditPost[]) {
const defaultThumbnails = ['default', 'none', 'nsfw'];
return posts
.map((post) => {
const thumbnail = post.data.thumbnail;
const modifiedThumbnail = defaultThumbnails.includes(thumbnail)
? `/assets/${thumbnail}.png`
: thumbnail;
const validThumbnail =
modifiedThumbnail.endsWith('.jpg') ||
modifiedThumbnail.endsWith('.png');
return {
src: this.getBestSrcForGif(post),
author: post.data.author,
name: post.data.name,
permalink: post.data.permalink,
title: post.data.title,
thumbnail: validThumbnail ? modifiedThumbnail : `/assets/default.png`,
comments: post.data.num_comments,
};
})
.filter((post): post is Gif => post.src !== null);
}
private getBestSrcForGif(post: RedditPost) {
// If the source is in .mp4 format, leave unchanged
if (post.data.url.indexOf('.mp4') > -1) {
return post.data.url;
}
// If the source is in .gifv or .webm formats, convert to .mp4 and return
if (post.data.url.indexOf('.gifv') > -1) {
return post.data.url.replace('.gifv', '.mp4');
}
if (post.data.url.indexOf('.webm') > -1) {
return post.data.url.replace('.webm', '.mp4');
}
// If the URL is not .gifv or .webm, check if media or secure media is available
if (post.data.secure_media?.reddit_video) {
return post.data.secure_media.reddit_video.fallback_url;
}
if (post.data.media?.reddit_video) {
return post.data.media.reddit_video.fallback_url;
}
// If media objects are not available, check if a preview is available
if (post.data.preview?.reddit_video_preview) {
return post.data.preview.reddit_video_preview.fallback_url;
}
// No useable formats available
return null;
}
}

Let’s first look at our first few sources:

//sources
private subredditChanged$ = this.subredditFormControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
startWith('gifs'),
map((subreddit) => (subreddit.length ? subreddit : 'gifs')),
);
subreddit = toSignal(this.subredditChanged$);
paginateAfter = linkedSignal({
source: this.subreddit,
computation: () => null as string | null,
});
gifsLoaded = rxResource({
params: () => ({
subreddit: this.subreddit(),
paginateAfter: this.paginateAfter(),
}),
stream: ({ params }) =>
this.fetchRecursivelyFromReddit(params.subreddit, params.paginateAfter),
});

The subredditChanged source is still mostly the same, it still uses the same RxJS operators, we are just converting it into a signal.

The introduction of this paginateAfter is where we first start to see significant changes. This is a signal that serves two purposes: it keeps track of our “last gif seen” so we know where to paginate from, and it is also how we trigger pagination. If we want to trigger loading more data we would .set this linkedSignal with the name of our “last gif”.

But, we also want this paginateAfter to have its value reset when the subreddit changes. So, we have this.subreddit as a source for the linkedSignal which will cause the computation to run every time it changes. This resets it back to null. But we can still update its value from null to the name of our “last gif” from elsewhere.

Then we get to the gifsLoaded source, which is what handles loading our data from the Reddit API:

gifsLoaded = rxResource({
params: () => ({
subreddit: this.subreddit(),
paginateAfter: this.paginateAfter(),
}),
stream: ({ params }) =>
this.fetchRecursivelyFromReddit(params.subreddit, params.paginateAfter),
});

We want this loading of gifs to be triggered under two conditions:

  1. When the subreddit changes
  2. When the paginateAfter value changes

So, we supply both of those in our params function. This will then trigger the stream using those values.

But, notice that we are actually using rxResource here. We will still be using RxJS to handle fetching the gifs recursively from the Reddit API by using the expand operator as before.

An important change here is that we have included this reduce operator after the expand:

reduce(
(acc, curr) => ({
...curr,
gifs: [...acc.gifs, ...curr.gifs],
}),
{
gifs: [] as Gif[],
paginateAfter: null as string | null,
gifsRequired: this.gifsPerPage,
subreddit: 'gifs',
},
),

An important thing to keep in mind when using rxResource is that, even though we can supply an observable as the loader, it will only return the first emission from the observable we supply to it. With our expand set up, it will cause the stream to emit the data it retrieves each time the stream “expands”. But since we are using rxResource it means we would only get the first set of results returned.

By using reduce we are able to collect all of the stream emissions into one single emission. This has the downside of requiring us to wait until we have all of the valid gifs before we are able to see anything, whereas with the more RxJS centric approach we are able to “stream” results in as they come.

This brings us to the final piece of this refactoring puzzle:

gifs = linkedSignal<ReturnType<typeof this.gifsLoaded.value>, Gif[]>({
source: this.gifsLoaded.value,
computation: (source, prev) => {
// initial and page loads
if (typeof source === 'undefined') return prev?.value ?? [];
// clear on subreddit change
if (
!prev ||
!prev.value[0]?.permalink.startsWith(`/r/${source.subreddit}`)
)
return source.gifs;
// accumulate values on paginate
return [...prev.value, ...source.gifs];
},
});

This is perhaps the most unintuitive and advanced of the changes we have made. First, we are supplying this type manually to linkedSignal:

linkedSignal<ReturnType<typeof this.gifsLoaded.value>, Gif[]>

This is basically saying that our source for the linkedSignal is going to be the values from our gifsLoaded resource (the data we just got from the Reddit API), and that this computation is going to return an array of gifs (Gif[]).

We want this gifs linkedSignal to be the gifs we intend to consume in the application.

But this is a complicated scenario because we can’t just take the values we get from gifsLoaded and set them into our gifs signal. We are using infinite scrolling, and we want newly loaded gifs added to the existing gifs…

That is unless we have changed subreddits, in that case we actually do want to clear out the existing gifs and only return the new ones.

That is what this computation is handling:

computation: (source, prev) => {
// initial and page loads
if (typeof source === 'undefined') return prev?.value ?? [];
// clear on subreddit change
if (
!prev ||
!prev.value[0]?.permalink.startsWith(`/r/${source.subreddit}`)
)
return source.gifs;
// accumulate values on paginate
return [...prev.value, ...source.gifs];
},

As well as passing in the value from our current source (i.e. the value from gifsLoaded), we can also pass in prev which is the result from the computation the last time it ran (i.e. our current array of gifs).

The basic scenario here is the last one, just combine the new gifs with the current gifs:

return [...prev.value, ...source.gifs];

But our source will be undefined initially and during page loads. When that is happening we make sure to just return the current gifs if there are any, otherwise we return an empty array:

if (typeof source === 'undefined') return prev?.value ?? [];

And if the results that have just come in are from a different subreddit than the previous results, we know we want to clear out all the gifs from the old subreddit and only return the new ones:

if (
!prev ||
!prev.value[0]?.permalink.startsWith(`/r/${source.subreddit}`)
)
return source.gifs;

…And that’s about it for the changes. Of course, just as we did with the Quicklists application we will also need to update how this service is consumed in the application, e.g. we will need to trigger page loads like this now:

<app-gif-list
[gifs]="redditService.gifs()"
infiniteScroll
(scrolled)="
redditService.paginateAfter.set(
redditService.gifsLoaded.value()?.paginateAfter ?? null
)
"
class="grid-container"
/>
@if (redditService.gifsLoaded.isLoading()) {
<mat-progress-spinner mode="indeterminate" diameter="50" />
}

Notice that I have also updated the spinner to show based on the isLoading signal of the gifsLoaded resource.

Summary

The usage of rxResource and linkedSignal here is much more advanced than what we used previously in the Quicklists application. This is actually probably more advanced than I think most situations you are likely to run into.

Whether this approach is better or worse than the original implementation is up for debate. Personally, I think it is a bit better.