Stimulus and RxJS for an SPA Like Experience

I really enjoy using Stimulus as a lightweight Javascript framework. With only a handful of attributes in your HTML and a few lines of Javascript, it’s possible to create some fairly advanced UI interactions. Lately, I’ve been combining Stimulus with RxJS to provide some protections around those interactions and improve the user experience with minimal code. Combining Stimulus and a few operators from RxJS, it’s possible to provide a fluid, SPA-like experience for your users.

I find this to be a great mix of clean code and improved usability, and was a technique I used quite a bit when building the new booking experience on Kwikcal.com.

The Experience

For this post, we’ll pretend to create a single page where a user can select from different movies in a list and have the details of that movie loaded dynamically through an XHR request, and display the details in a bottom panel below. This is a fairly straight forward interaction but can have a few gotcha’s that are easily overlooked.

The basics of our app
The basics of our app

What we want is that when a user clicks on a thumbnail for a movie, we’ll dynamically load the details of that movie. We also want some loading indicators so the user knows that something is taking place, but only if the user is on a slow connection. Also, if the user is on a slow connection and clicks through on different movies, we want to make sure that we always display the movie the user last clicked on - regardless of which request finished last.

Basic Interaction

To start with, we’ll want to setup Stimulus so that our user can click on a thumbnail and load the content. In our case, we’re using Rails as a backend to return a partial, but this should work with any backend framework that can render HTML.

<div class="movies" data-controller="movies">
  <div class="movie-cards">
    <% @movies.each do |movie| %>
      <div class="movie-card" data-target="movies.movieItem"
           data-action="click->movies#loadMovie"
           data-movie-url="<%= movie_path(movie[:slug]) %>">
        <div class="movie-item-content">
          <%= image_tag(movie[:thumb]) %>

          <div class="movie-item-detail text-center">
            <%= movie[:title] %>
          </div>
        </div>
      </div>
    <% end %>
  </div>

  <div class="movie-detail">
    Movie detail goes here...
  </div>

</div>

Here is what our index page looks like, with some Stimulus attributes set up to define what our controller is, the targets for our movie item, and the URL for the partial that we will eventually load. This is what I love about Stimulus - all of our code set up is in HTML, and we’re able to even leverage things such as our route helpers so that our Javascript doesn’t even care about how our routes are structured. All of that logic lives in a single source - our backend app.

Up above, we set up an action data-action="click->movies#loadMovie". This tells Stimulus what action on our controller to call when a user clicks on that element. Let’s hook that up in our Controller.

import { Controller } from 'stimulus';

export default class extends Controller {

  loadMovie(event) {
    let target = event.currentTarget;
    let movieSlug = target.getAttribute('data-movie-url');
    console.log(`User clicked on ${movieSlug}`);
  }
}

So far we haven’t done much. We’ve added a method for our click event, and right now we’re just logging to the console what the user loaded. To get the data for the movie, we’ll pull in RxJS which has a lot of utility methods we’ll take advantage of.

A sprinkling of reactivity

RxJS is a library for doing reactive programming. If you’re coming from the Angular world, like I am, you’re probably familiar with it already. RxJS is a “library for composing asynchronous and event-based programs by using observable sequences”.

It’s easier to see it in action, and the official docs can explain the basics better than I could. Let’s add RxJS to our project and get going.

yarn add rxjs

Fortunately, RxJS as a library is broken down into smaller parts that we can use. The first thing we’ll want to do is set up some Subject’s to observe for when a user clicks on a movie.

First, we’ll want to import a few things from the rxjs library. Specifically, ajax, Subject and map as an operator.

import { Controller } from 'stimulus';
import { Subject } from 'rxjs';
import { map } from 'rxjs/operators';

Next, we want to set up a new Subject which we’ll observe. We’ll use the connect lifecycle callback from Stimulus to set this up every time a new controller is created. This subscription will be used to listen to changes in the selected movie, and then output the URL to load. We’ll expand on this later on, but let’s take baby steps to get our observables in order.

import { Controller } from 'stimulus';
import { Subject } from 'rxjs';
import { map } from 'rxjs/operators';

export default class extends Controller {

  movieItem$ = new Subject();

  connect() {
    this.setupMovieClick();
  }
  
  disconnect() {
    // We need to make sure that we unsubscribe to the stream,
    // otherwise we could have memory leaks
    this.movieItem$.unsubscribe();
  }

  loadMovie(event) {
    let target = event.currentTarget;
    let movieSlug = target.getAttribute('data-movie-url');
    this.movieItem$.next(movieSlug);
  }

  setupMovieClick() {
    this.movieItem$
      .pipe(
        map((movieUrl) => {
          return movieUrl;
        })
      )
      .subscribe((response) => {
        console.log(`Displaying: ${response}`);
      });
  }
}
Every time you subscribe to an Observable, it's important to remember that you must always unsubscribe, in order to prevent a memory leak.

If you’re new to RxJS this probably looks a bit crazy - so let’s go through it one section at a time.

The first thing we want to do is set up an observable that we’ll listen to and react to when a user event takes place. This is what our Subject is - the movieItem$. It’s common practice in RxJS to name our streams ending in a dollar sign, to further clarify what they are.

Second, we create a private method to set up the movie click. Here, we use pipe to set up a series of operations that take place every time a new item is added to the stream. In this initial example, we just want to map and return the URL. Later on, we’ll expand that logic a bit.

Finally, we call .subscribe on that stream and specify a callback to take place after all of the pipe operations have completed. It’s important to know that nothing takes place until we actually subscribe to a stream. So if you had native events or XHR requests, they wouldn’t actually fire until we subscribe to the stream.

Finally, we take our old loadMovie method and change it up a bit. We tell the stream that there is a new value, by calling .next with the path to the partial. This is what triggers our subscription callback to fire, which calls our console.log statement.

Now that this is done, if we refresh the page and click on a thumbnail we should see the URL of the movie to load in our console.

Loading the movie details

Now that we know our button clicks are all wired up and reactive, let’s go and retrieve the data for the movie details.

The first thing we’ll want to do is import the ajax command from RxJS. We do that by adding the following to the top of our file:

import { ajax } from 'rxjs/ajax';

We’ll also want to import switchMap from rxjs/operators, as we’ll want to use this for the XHR requests instead of a simple map operation.

Here is what our pipe should now look like:

setupMovieClick() {
  this.movieItem$
    .pipe(
      switchMap((movieUrl) => {
        return ajax({
          method: 'GET',
          url: movieUrl,
          responseType: 'text'
        });
      }),
      map((response) => {
        return response.response;
      })
    )
    .subscribe((response) => {
      console.log(response);
    });
}

What we’re doing is instead of doing a map to output the URL, we do a switchMap and return our ajax call to load our partial.

switchMap is similar to a regular map but has the added benefit of only returning the latest request. This means that if the user is on a less than ideal connection and is quickly clicking on many movie thumbnails, switchMap will do all the dirty work of making sure that only the last click will be processed, instead of the last request result.

What this means in practice is that our clicks don’t get out of sync. Say, for example, the user clicks on movie 1, and that takes 450ms to respond, but before that happens, the user clicks on movie 2, and now that only takes 100ms to respond. Even though the last response is for movie 1, the last requested item is movie 2, and that is what the map will return.

In fact, switchMap is even smart enough to cancel any of the old HTTP requests for us!

Screenshot of the Chrome devtools showing our canceled requests
Screenshot of the Chrome devtools showing our canceled requests

Now what we want to do after our switchMap, which returns the results of our XHR request, is return a map which takes the text response from our request and maps that to any subscriber.

If we run this now, we’ll get the movie details for the last item the user clicked on, regardless of the completion order of the network requests.

Displaying the details

Now that we have the movie details as a string from our partial, we can use some Stimulus targets to inject that content into the DOM. We’ll want to take our old placeholder div and tell Stimulus about it, by specifying it as a target.

<div class="movie-detail" data-target="movies.movieDetails">
  Movie detail goes here...
</div>

Then, at the top of our controller, we tell Stimulus about it, so we can access it from our controller.

static targets = ['movieDetails'];

Finally, we’ll modify our subscription callback to update the innerHTML of this element:

.subscribe((response) => {
  this.movieDetailsTarget.innerHTML = response;
});

With this, we should now see the movie details as we click around on the screen. Nice!

Loading Indicator

So far, this is looking pretty good. The user can click on a movie card and see the details of the movie. Even better, with our reactive programming approach, even users on a less than ideal connection don’t get any state out of sync by having XHR requests returned out of order. They always see the last item they clicked on. One challenge left is to add a loading indicator, so users on a slower connection can see a visual indicator that we’re fetching their data.

To keep things simple, we’ll use an animated gif from loader.io, and put it in our template. We could also just have easily used an SVG and some CSS, but that’s out of scope for this article.

The first thing we’ll want to do is to put the loading state in our template and tell our Stimulus controller about it.

<div class="loading-indicator hidden"
     data-target="movies.movieDetailsLoading">
  <%= image_tag("loading.gif") %>
</div>
static targets = ['movieDetails', 'movieDetailsLoading'];

Now that Stimulus knows about it, we can do some simple CSS to hide and show the movie details content and the loading content. Our code is getting a bit complex, so we should probably take the opportunity to refactor the displaying of the loading state and displaying of the movie content into separate methods.

So how do we hook into our subscription to know when a movie is loading? Fortunately, RxJS has our back, and we can simply tap into the pipe to call our loading code without affecting anything that gets returned from the pipe. Let’s see what that looks like, along with our refactored code.

setupMovieClick() {
  this.movieItem$
    .pipe(
      tap(() => {
        this.displayLoadingState();
      }),
      switchMap((movieUrl) => {
        return ajax({
          method: 'GET',
          url: movieUrl,
          responseType: 'text'
        });
      }),
      map((response) => {
        return response.response;
      })
    )
    .subscribe((response) => {
      this.displayMovieContent(response);
    });
}

displayLoadingState() {
  this.movieDetailsLoadingTarget.classList.remove('hidden');
  this.movieDetailsTarget.classList.add('hidden');
}

displayMovieContent(movieContent) {
  this.movieDetailsTarget.innerHTML = movieContent;

  this.movieDetailsLoadingTarget.classList.add('hidden');
  this.movieDetailsTarget.classList.remove('hidden');
}

Here what we do is we call tap with a function that gets called in our event stream, after an item is clicked, and before the ajax request is fired off. Then, we target the elements through Stimulus and add some utility classes to hide the different elements.

Now, when we click on a movie, we’ll see a little spinner display before the movie details are displayed. Great!

The only downside here is that if a user is on a fast connection, then they’ll see a quick flash of the loading spinner before that gets displayed.

Loading Indicator Timeout

One way to work around the loading indicator for users on a fast connection is to put the code to display the spinner on a timeout. If it’s been 250 milliseconds and we haven’t gotten data from the server back, we should display a spinner. As soon as we get data back, let’s cancel the timeout. This has the benefit of only displaying the spinner for users on a slow connection.

setupMovieClick() {
  let loadingIndicatorTimeout;

  this.movieItem$
    .pipe(
      tap(() => {
        loadingIndicatorTimeout = setTimeout(() => {
          this.displayLoadingState();
        }, 250);
      }),
      switchMap((movieUrl) => {
        return ajax({
          method: 'GET',
          url: movieUrl,
          responseType: 'text'
        });
      }),
      tap(() => {
        clearTimeout(loadingIndicatorTimeout);
      }),
      map((response) => {
        return response.response;
      })
    )
    .subscribe((response) => {
      this.displayMovieContent(response);
    });
}

What we’re doing is taking our initial tap function, and wrapping it in a setTimeout to run after 250ms. Then, in a new tap operation, we cancel the timeout, as that means we’ve retrieved the data from the server. If the timeout never reached 250, such as a user on a fast connection, then the loading spinner never fires. If it’s already been displayed, then nothing happens, as the callback already took place.

Click on details on a slow connection and user will see the loading indicator
Click on details on a slow connection and user will see the loading indicator

Final Touches

Wow - we’ve achieved a lot, with only a handful of code in our Stimulus controller. There’s one final touch that we should add. Currently, if a user clicks on a movie, we display that movie in the details panel below. However, if a user continually clicks on the same movie tile, we keep making the same request over and over again.

Once again, RxJS has our back - this time with the distinctUntilChanged operator. Add this operator as the first operator in our pipeline, and the rest will only fire when the stream item has changed - which in our case is the movie detail URL to load.

this.movieItem$
  .pipe(
    distinctUntilChanged(),
    tap(() => {
      loadingIndicatorTimeout = setTimeout(() => {
        this.displayLoadingState();
      }, 250);
    }),
    // etc etc.

Another behavior we should add is to highlight the selected movie. This is something we can do using Stimulus in our click handler.

Since we’ve already added a data-target for our movie items in our template, we just need to add those as a static target in our controller. From there, we can use basic CSS and DOM manipulation to identify the selected card.

static targets = ['movieItem', 'movieDetails', 'movieDetailsLoading'];

loadMovie(event) {
  let target = event.currentTarget;
  let movieSlug = target.getAttribute('data-movie-url');

  this.movieItemTargets.forEach((movieItem) => {
    movieItem.classList.remove('selected');
  });

  target.classList.add('selected');

  this.movieItem$.next(movieSlug);
}

Now our final product is looking pretty good. As the user clicks around the movies, they get visual indicators if they are loading, visual indicators of their selected movie and any slow or untimely requests are easily handled for us by RxJS.

Not bad for around 75 lines of Javascript!

Final product

Final product

If you’d like to see the final code, please feel free to browse the mike1o1/stimulus-rxjs-example repository.

comments powered by Disqus