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.
Update the
RedditService
to reflect the following:
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,
debounceTime,
distinctUntilChanged,
expand,
map,
startWith,
} from 'rxjs';
@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({
request: () => ({
subreddit: this.subreddit(),
paginateAfter: this.paginateAfter(),
}),
loader: ({ request }) =>
this.fetchRecursivelyFromReddit(request.subreddit, request.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;
}),
);
}
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;
}
}