Most IntersectionObserver
demos show off how to implement infinite scrolling for news feeds and similar use cases. One of my most recent encounters, though, was concerned with a product image slider, horizontally of course. In the not so far away past, this would have meant crafting JSON endpoints to obtain paginated resources, render them as HTML and write all the necessary glue code, easily the workload of a full day. With CableReady and one of Adrien Poly’s stimulus-use controllers, this can all be done in a very descriptive way in just a few lines of code.
1. Setup
To demonstrate this, I’m going to use the pagy gem. Let’s get started by creating a new Rails app and installing all the dependencies.
$ rails new horizontal-slider-cable-ready
$ cd horizontal-slider-cable-ready
$ bundle add cable_ready pagy
$ bin/yarn add cable_ready stimulus-use
$ bin/rails webpacker:install
$ bin/rails webpacker:install:stimulus
To get some styling for our demo, let’s also set up tailwind quickly:
$ bin/yarn add tailwindcss
$ npx tailwindcss init
Create app/javascript/styles/application.scss
, adding the tailwind setup and an intentionally ugly styling for the observer sentinel
.
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
In app/javascript/packs/appliction.js
, add the stylesheet:
require("@rails/ujs").start();
require("turbolinks").start();
require("@rails/activestorage").start();
require("channels");
import "../styles/application";
import "controllers";
Because tailwind is a postcss plugin, we need to set it up in postcss.config.js
:
module.exports = {
plugins: [
require("autoprefixer"),
require("tailwindcss")("tailwind.config.js"),
// ...
]
}
Furthermore, in app/views/layouts/application.html.erb
, exchange stylesheet_link_tag
with stylesheet_pack_tag
:
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
For our CableReady setup, let’s create a SliderChannel
(app/channels/slider_channel.rb
)
class SliderChannel < ApplicationCable::Channel
def subscribed
stream_from "slider-stream"
end
end
along with the JavaScript counterpart in app/javascript/channels/slider_channel.js
, where in the receive
hook, we instruct CableReady to actually perform its operations:
import CableReady from "cable_ready";
import consumer from "./consumer";
consumer.subscriptions.create("SliderChannel", {
received(data) {
if (data.cableReady) CableReady.perform(data.operations);
}
});
2. Backend Necessities
So much for the boilerplate. To efficiently test our implementation, let’s create an Item
scaffold and 1000 instances:
$ bin/rails g scaffold Item --no-javascripts --no-assets --no-helper
$ bin/rails db:migrate
$ bin/rails r "1000.times { Item.create }"
Now, let’s dive into the interesting stuff. Because we don’t want to load all 1000 instances of Item
right away, we’re going to adapt the index
action in app/controllers/items_controller.erb
to use pagination:
class ItemsController < ApplicationController
include Pagy::Backend # <--
# GET /items
# GET /items.json
def index
@pagy, @items = pagy Item.all, items: 10 # <--
end
# ...
end
In the app/views/items/index.html.erb
view, we create a container for the slider and add CSS to set the appropriate overflow
and white-space
attributes, so that we can scroll horizontally and to avoid line breaks.
<h1>Items</h1>
<div id="slider-container" class="w-screen overflow-x-scroll overflow-y-none whitespace-no-wrap">
<%= render "items/items", items: @items, pagy: @pagy %>
</div>
Within app/views/items/_items.html.erb
, we render the items
collection, along with the slider-sentinel
. This last piece of markup is the central building block of our implementation: Whenever it comes into the viewport, it is going to trigger lazy loading of new items from the server. To do this, we instrument it with a lazy-load
stimulus controller that we are going to write in the next step, along with the URL to fetch when it comes into view. We simply use the items_path
here and pass the next page, and js
as a format (which I’ll come back to later).
The last bit of explanation necessary here concerns the if
conditional the sentinel is wrapped in: When there are no more pages to load, we don’t want to display it because it will only lead to a 404 when trying to fetch a page that doesn’t exist.
<%= render items %>
<% if pagy.page < pagy.last %>
<div id="slider-sentinel" class="inline-block w-4 h-48 text-3xl bg-orange-500" data-controller="lazy-load" data-lazy-load-next-url="<%= items_path(page: pagy.page + 1, format: :js) %>">
<div class="flex w-full h-full justify-center items-center"> </div>
</div>
<% end %>
For completeness sake, here’s our app/views/items/_item.html.erb
partial:
<div class="w-64 h-48 text-3xl border border-gray-400">
<div class="flex w-full h-full justify-center items-center">
<%= item.id %>
</div>
</div>
3. Adding Frontend Reactivity
Okay, now it’s time to write the necessary JS sprinkles: in app/javascript/controllers/lazy_load_controller.js
, we import useIntersection
from the excellent stimulus-use
library and call it in the connect
callback of our controller. Essentially, this instruments our controller, or rather the DOM element it is attached to, with an IntersectionObserver
that will call the controller’s appear
method once it slides into the viewport.
So we implement this method and have it fetch more content via Rails.ajax
and the url we specified above when attaching the controller to the sentinel:
import { Controller } from "stimulus";
import { useIntersection } from "stimulus-use";
import Rails from "@rails/ujs";
export default class extends Controller {
connect() {
useIntersection(this, {
rootMargin: "0px 0px 0px 0px",
root: document.querySelector("#slider-container"),
threshold: 0
});
}
appear() {
this.loadMore(this.nextUrl);
}
loadMore(url) {
Rails.ajax({
type: "GET",
url: url
});
}
get nextUrl() {
return this.data.get("nextUrl");
}
}
Now let’s get to the real meat - we include CableReady::Broadcaster
in our items_controller.rb
and split our logic between different formats. This is mainly a trick to avoid writing a second controller action plus routing, when everything is already so neatly set up.
In the format.js
block, we set up CableReady
to exchange the sentinel’s outer_html
(i.e., itself) with the contents of the next page’s partial (which, as you can inspect above, includes a new sentinel again). It’s this recursive structure that makes this approach especially elegant.
Observe that we call render_to_string
with layout: false
and set the content_type
to text/html
:
class ItemsController < ApplicationController
include Pagy::Backend
include CableReady::Broadcaster # <--
# GET /items
# GET /items.json
def index
@pagy, @items = pagy Item.all, items: 10
respond_to do |format| # <--
format.html
format.js do
cable_ready["slider-stream"].outer_html(
selector: "#slider-sentinel",
focusSelector: "#slider-sentinel",
html: render_to_string(partial: "items/items", locals: { items: @items, pagy: @pagy }, layout: false, content_type: "text/html") # <--
)
cable_ready.broadcast
end
end
end
# ...
end
Now when we scroll to the right, we briefly see that orange bar appearing while simultaneously the next 10 items are loaded:
We can of course utilize all available IntersectionObserver
options to adjust the behavior. For example, by setting rootMargin
to 0px 100px 0px 0px
new content is loaded before the sentinel even appears in the viewport by (invisibly) extending the bounding box:
connect() {
useIntersection(this, {
rootMargin: "0px 100px 0px 0px",
root: document.querySelector("#slider-container"),
threshold: 0
});
}
Further Reading
- If you’d like to know more about how you can use CableReady, head over to cableready.stimulusreflex.com
- @hopsoft recently published a short (2.5 min) overview video about how CableReady works: https://www.youtube.com/watch?v=dPzv2qsj5L8
- there’s also a free Gorails episode on CableReady: https://www.youtube.com/watch?v=grnQ46lNDAc