Skip to content

Creating a Dumb Component to Display GIFs

As usual, we are going to start by implementing a feature that moves us toward the main goal of the application as quick as possible. This is an application that displays GIFs, so we are going to start by creating our dumb/presentational component that handles displaying the GIFs.

Remember, in this application build I am going to go very light on the instructions and explanations. In some places I will be intentionally vague and leave steps out (e.g. like not reminding you to import dependencies for a particular component we built). If you forget to do this you should run into errors, and this will be good practice in reading and trying to decipher the errors. Again, I want to reiterate that this is not because I expect this to be easy for you now. I expect that this will likely cause most people to get stuck on things, and that is the point.

Whenever we are implementing something we haven’t seen before, I will still explain it thoroughly.

Create an interface for GIFs

export interface Gif {
src: string;
author: string;
name: string;
permalink: string;
title: string;
thumbnail: string;
comments: number;
}

This might seem like a little bit more than we usually do. We will not be making use of all of these fields immediately, but eventually this is all of the data that we are going to want to retrieve for a GIF from Reddit.

We are also going to be creating a lot of different interfaces for this application, so for convenience sake, we are going to create a single index.ts file that we can use to import any of our interfaces by exporting them all from that file.

export * from './gif';

Any time we add a new interface, we will export it from this file.

Create the GifListComponent

import { Component, input } from '@angular/core';
import { Gif } from '../../shared/interfaces';
@Component({
selector: 'app-gif-list',
template: `
@for (gif of gifs(); track gif.permalink){
<div>
{{ gif.title }}
</div>
}
`,
})
export class GifListComponent {
gifs = input.required<Gif[]>();
}

For now, all we are doing is looping through the supplied gifs and rendering the title. We just want to get some data displaying in the application first, but soon we will come back to this component and extend it to actually display the gifs.

This will involve creating another dumb component that will fill the role of displaying the gifs/videos.

Create the Reddit Service

Before we can use our new component, we are going to need to be able to fetch the data we need to supply to it. We are going to do that now so that we can add it to our home page, but we will not be implementing the full Reddit API integration right away. For now, we will just return some dummy data.

import { Injectable, computed, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Gif } from '../interfaces';
import { of } from 'rxjs';
export interface GifsState {
gifs: Gif[];
}
@Injectable({ providedIn: 'root' })
export class RedditService {
// state
private state = signal<GifsState>({
gifs: [],
});
// selectors
gifs = computed(() => this.state().gifs);
// sources
gifsLoaded$ = of([
{
src: '',
author: '',
name: '',
permalink: '',
title: 'test gif',
thumbnail: '',
comments: 0,
},
]);
constructor() {
//reducers
this.gifsLoaded$.pipe(takeUntilDestroyed()).subscribe((gifs) =>
this.state.update((state) => ({
...state,
gifs: [...state.gifs, ...gifs],
}))
);
}
}

This is probably starting to look pretty standard by now — it’s our same basic state set up again. This time we have a gifsLoaded$ source that will emit with our gifs, and for now we are just creating some dummy data by creating an observable of an array by using the of creation operator from RxJS.

The of operator will take whatever we supply it and create a stream that emits that data. The purpose of this here, rather than just returning the data directly, is to simulate the data being loaded via HttpClient. The HttpClient needs to make an asynchronous request and returns the data as a stream, so for now we are just creating a fake implementation of that where we immediately return the data as a stream.

Whenever you run into RxJS operators you don’t understand — and this will happen a lot as it takes some time to get used to them — I would recommend looking up the operator on learnrxjs.io. There is also the official documentation at rxjs.dev but this is generally more on the technical side and likely better suited once you are more comfortable with RxJS. The Learn RxJS website has more examples that you can look at to see what is going on. Of course, ChatGPT is actually generally quite good at answering questions about RxJS operators too if you have access to that.

Later we will swap this out for a stream that actually loads the data from the API.

Adding the GifListComponent to the Home Page

I will still post the code for this one in a moment, but see if you can add the component we just created to the template of the home component, and supply it with the data it needs from the RedditService.

import { Component, inject } from '@angular/core';
import { GifListComponent } from './ui/gif-list.component';
import { RedditService } from '../shared/data-access/reddit.service';
@Component({
selector: 'app-home',
template: `
<app-gif-list [gifs]="redditService.gifs()" class="grid-container" />
`,
imports: [GifListComponent],
})
export default class HomeComponent {
redditService = inject(RedditService);
}

Our component should now successfully be displaying on our home page with just the test title for now. We have also added a class to the component that we will use for styling later.

Create the GifPlayerComponent

We’ve got the basic wiring in place now, but displaying our gifs will be a little bit more involved than just rendering some text as we are doing currently.

In fact, this is going to be the first time we will be managing complex state within a dumb component. Usually we deal with our state in services, but this component will have its state management built into the component itself.

The responsibility of this component will be to display the gifs (they are actually videos), but to do that we will need to handle:

  • Rendering the video
  • Loading the video when the user taps the video
  • Playing the video when it has finished loading
  • Displaying a loading spinner whilst the video is loading
  • Pausing the video when the user taps the video (if it is playing)

We want to make sure all of this happens seamlessly — for example, if a user is spam tapping a video whilst it is loading we want to make sure we don’t get into any weird states that cause unwanted behaviour. This is why the robustness of our state management approach will work well here.

Since this component is quite complex, we are going to start with a basic outline, and then we will fill in some more details.

following:

import {
Component,
ElementRef,
input,
viewChild,
computed,
signal,
} from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Subject } from 'rxjs';
interface GifPlayerState {
playing: boolean;
status: 'initial' | 'loading' | 'loaded';
}
@Component({
selector: 'app-gif-player',
template: `
@if (status() === 'loading'){
<mat-progress-spinner mode="indeterminate" diameter="50" />
}
<div>
<video
(click)="togglePlay$.next()"
#gifPlayer
playsinline
preload="none"
[loop]="true"
[muted]="true"
[src]="src()"
></video>
</div>
`,
imports: [MatProgressSpinnerModule],
})
export class GifPlayerComponent {
src = input.required<string>();
thumbnail = input.required<string>();
videoElement = viewChild.required<ElementRef<HTMLVideoElement>>('gifPlayer');
videoElement$ = toObservable(this.videoElement);
state = signal<GifPlayerState>({
playing: false,
status: 'initial',
});
//selectors
playing = computed(() => this.state().playing);
status = computed(() => this.state().status);
// sources
togglePlay$ = new Subject<void>();
constructor() { }
}

There is already quite a lot here even for the basic outline, so let’s talk through it. We have the same general state set up, and we are tracking the following state for this component:

interface GifPlayerState {
playing: boolean;
status: 'initial' | 'loading' | 'loaded';
}

We want to know if the gif is currently supposed to be playing, and we want to know its status:

  • Has no load been triggered yet? initial
  • Has a load been triggered but it has not finished? loading
  • Has the load finished? loaded

We tie into our loading state to display this spinner:

@if (status() === 'loading'){
<mat-progress-spinner mode="indeterminate" diameter="50" />
}

This is the first Angular Material component we are using — quite simply it displays a loading spinner.

Our video element is mostly pretty standard:

<video
(click)="togglePlay$.next()"
#gifPlayer
playsinline
preload="none"
[loop]="true"
[muted]="true"
[src]="src()"
></video>

We are triggering our togglePlay$ source when it is clicked (which will eventually trigger the load of the video). The rest are just the standard HTML5 video options, except for the template variable of #gifPlayer that we have created. We have done this so that we can grab a reference to this video element, which will allow us to listen for its load start/complete events (this will be an important part of our state management process).

The next section is where it starts to get a bit weird:

videoElement = viewChild.required<ElementRef<HTMLVideoElement>>('gifPlayer');
videoElement$ = toObservable(this.videoElement);

We are grabbing a reference to an element in our template with viewChild which is something we are at least somewhat familiar with at this point… but then we are converting the videoElement signal into an observable.

In a moment, we are going to create sources for our state management that utilise events from our video element. That is why we create this observable stream:

videoElement$ = toObservable(this.videoElement);

This will allow us to chain observable operators onto this element to get access to the specific events we are interested in.

With that, we can set up our sources.

videoLoadStart$ = this.togglePlay$.pipe(
switchMap(() => this.videoElement$),
switchMap(({ nativeElement }) => fromEvent(nativeElement, 'loadstart')),
);
videoLoadComplete$ = this.videoElement$.pipe(
switchMap(({ nativeElement }) => fromEvent(nativeElement, 'loadeddata')),
);

The videoLoadComplete$ source above is somewhat straight-forward. We want it to emit when the loadeddata event from our video element when it is triggered. So, we start with our videoElement$ stream that will emit the video element, then we use switchMap to return a different stream. By using the RxJS fromEvent creation operator, we can pass in the reference to our HTML video element (element) and the event we are interested in listening for on that element (loadeddata) and it will give us a stream that will emit when that event emits.

We start with the videoElement$ stream because we need something from that stream — the video element in the DOM — using switchMap allows us to start with that stream, get the information we need from it, and then use that information to switch to the stream we actually want: a stream that emits when the loadeddata event emits on the video DOM element.

The videoLoadStart$ source is sort of the same idea:

videoLoadStart$ = this.togglePlay$.pipe(
switchMap(() => this.videoElement$),
switchMap(({ nativeElement }) => fromEvent(nativeElement, 'loadstart')),
);

We would be doing essentially the same thing: switchMap to an observable created from the loadstart event on the element. However, what I assume is a browser bug complicates this. We only want the load of a video to be triggered when a user specifically taps on a video. But if we just do what we did with the videoLoadComplete$ source, the load of the video will be triggered by the mere fact of setting up the stream to listen for the loadstart event.

To work around this, we instead use our togglePlay stream so that we only ever switch to the loadstart stream after a play is triggered. That means that if togglePlay$ has not emitted yet, then the fromEvent will not be subscribed to. However, if we were to not utilise togglePlay$ here and instead just use videoElement$ then the fromEvent would be subscribed to straight away.

It is worth considering again why we are doing things this way. Ultimately, for our declarative approach we want our sources to be representative of the things we want to react to in order to set our state. If we want to change our state based on a <video> element loading, then we need a source that will emit when that <video> element loads.

We could set this up in a slightly more imperative way. For example:

// sources
videoLoadComplete$ = new Subject<void>();
ngAfterViewInit(){
this.videoElement().addEventListener('loadeddata', () => {
this.videoLoadComplete$.next();
});
}

The ngAfterViewInit hook won’t run until after the view has initialised, and at this point our gifPlayer will be defined. At that point, we can add an event listener imperatively with a callback function that calls next on the videoLoadComplete$ source. This is a more imperative approach, but it’s not actually that bad and this might be one of those cases where you just say: you know what, I’m just going to take the imperative shortcut here. We are at least getting the data back into a reactive flow after we next the videoLoadComplete$ source.

If you can’t figure out how to do something declaratively or it is too much work, it is okay to do stuff like this. I would just urge you to use it sparingly, as what we are missing out on here is the declarative definition of the videoLoadComplete$ source.

This tells us everything we need to know about how it is calculated:

videoLoadComplete$ = this.videoElement$.pipe(
switchMap((element) => fromEvent(element, 'loadeddata'))
);

This tells us nothing:

videoLoadComplete$ = new Subject<void>();

If we need to take the imperative approach here for whatever reason, it isn’t really a big deal. But, if we start being too loose with where we take these shortcuts we will lose the benefits of coding declaratively — it will be harder to understand how the code works, it will be harder to combine things together and have everything update automatically when dependencies change, and there will probably be more bugs.

I usually tend to go with the declarative approach unless it makes things significantly harder or more awkward. If you are newer to RxJS and reactive/declarative code then the bar for what is too hard might be lower, and it is fine to make more exceptions. Just keep practicing and attempting to do things the declarative way, and over time thinking in streams and utilising RxJS operators will come much more naturally to you. In the beginning, it is hard and there is no getting around that.

With those new sources in place, let’s set up all of our reducers:

constructor() {
//reducers
this.videoLoadStart$
.pipe(takeUntilDestroyed())
.subscribe(() =>
this.state.update((state) => ({ ...state, status: 'loading' }))
);
this.videoLoadComplete$
.pipe(takeUntilDestroyed())
.subscribe(() =>
this.state.update((state) => ({ ...state, status: 'loaded' }))
);
this.togglePlay$
.pipe(takeUntilDestroyed())
.subscribe(() =>
this.state.update((state) => ({ ...state, playing: !state.playing }))
);
}

This should hopefully feel a little more comfortable — the hard part is setting up the sources, these just update the state with the appropriate values.

There is one thing left for us to do, and that is to set up an effect that will help us control when videos load and play.

effect(() => {
const { nativeElement: video } = this.videoElement();
const playing = this.playing();
const status = this.status();
if (!video) return;
if (playing && status === 'initial') {
video.load();
}
if (status === 'loaded') {
playing ? video.play() : video.pause();
}
});

The functionality we are trying to implement is not as simple as:

  • User clicks video
  • Video loads
  • Video plays

What if the user clicks a video, it starts loading, but then the user clicks it again to pause it before the load finishes? How do we react to the load finishing so that we can play the video if necessary?

The effect handles all of this. Any time any of our signals change — the videoElement, playing, or status signals — this effect will run. If playing is true and the status is initial (i.e. no load has been triggered yet) then we trigger a load. If the status is loaded then we either play or pause the video depending on the current playing state.

Styling the GifPlayer

I promise we’re almost done here, but there is a little bit more we are going to do before the end of this lesson.

Usually, I would leave the styling until later and just focus on the functionality. But we are going to add just a few styles now otherwise our component is going to look a bit wonky.

styles: [
`
:host {
display: block;
position: relative;
overflow: hidden;
max-height: 80vh;
}
.preload-background {
width: 100%;
height: auto;
}
.blur {
filter: blur(10px) brightness(0.6);
transform: scale(1.1);
}
video {
width: 100%;
max-height: 80vh;
height: auto;
margin: auto;
background: transparent;
}
mat-progress-spinner {
position: absolute;
top: 2em;
right: 2em;
z-index: 1;
}
`,
],

The main ideas here are to keep the video player at a sensible size (otherwise some videos will be larger than the screen) and to also display the loading spinner on top of the video rather than above it.

We are going to add some more advanced styling later to make the video player look a lot nicer and to incorporate a thumbnail before a video is played, but this will do for now.

Add the GifPlayer to the GifList

Now we just need to add our GifPlayerComponent to our GifListComponent.

import { Component, input } from '@angular/core';
import { Gif } from '../../shared/interfaces';
import { GifPlayerComponent } from './gif-player.component';
@Component({
selector: 'app-gif-list',
template: `
@for (gif of gifs(); track gif.permalink){
<div>
<app-gif-player
[src]="gif.src"
[thumbnail]="gif.thumbnail"
></app-gif-player>
</div>
}
`,
imports: [GifPlayerComponent],
})
export class GifListComponent {
gifs = input.required<Gif[]>();
}

For all our hard work, it might be disappointing to see that… we can’t actually see anything!

In fact, the screen will be totally empty. We don’t actually have any real gifs/videos loaded into the application yet, so we can’t marvel at the magnificence of our GifPlayerComponent. There will actually be a <video> element rendered to the screen, you just can’t see it. To make sure things are at least somewhat working, you can click that invisible <video> element and you should see the loading spinner appear.

We will tackle loading real data into our application in the next lesson.