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.
Click here to reveal solution
Solution
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:
// sourcesvideoLoadComplete$ = 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.