DEV Community

Ahmad khattab
Ahmad khattab

Posted on

Infinite Scroll with HOTWire - Part 2: Adding Stimulus

You can follow along if you missed the first part.

Adding Stimulus

Now as our data is ready and we can scroll to the bottom of the screen. We are ready to add a stimulus controller that is responsible for the pagination.

first, create a new file at app/javascript/controllers/pagination_controller.js

// pagination_controller.js


import { Controller } from "stimulus";

export default class extends Controller {
  static values = {
    url: String,
    page: Number,
  };

  initialize() {
    this.scroll = this.scroll.bind(this);
    this.pageValue = this.pageValue || 1;
  }

  connect() {
     document.addEventListener("scroll", this.scroll);
  }

  scroll() {
    if (this.scrollReachedEnd) {
      this._fetchNewPage()
    }
  }

  async _fetchNewPage() {
    // fetch new url
    // update new page
    // ensure that we are on the last page
  }

    get scrollReachedEnd() {
        const { scrollHeight, scrollTop, clientHeight } = document.documentElement;
        const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
        return distanceFromBottom < 20; // adjust the number 20 yourself
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

It's a simple controller that attaches a scroll listener on the element and calls _fetchNewPage when the scroll has reached the end. Now, let's populate the method body.

request.js is a minimalistic JavaScript pacakge that is set to replace Rails UJS in the near future. We will be using it to fetch new pages from the server. Let's install the package

yarn add @rails/request.js
Enter fullscreen mode Exit fullscreen mode

Adding logic to the method body

What we want to do is that when the _fetchNewPage method is called, a) request the server the urlValue and add the pageValue as a query param.

  async _fetchNewPage() {
    const url = new URL(this.urlValue);
    url.searchParams.set('page', this.pageValue)

    await get(url.toString(), {
      responseKind: 'turbo-stream'
    });

    this.pageValue +=1;
  }
Enter fullscreen mode Exit fullscreen mode

Let's connect the controller to the dom.

<div
  data-controller="pagination"
  data-pagination-url-value="<%= posts_url %> "
  data-pagination-page-value="<%= 2 %>">
  <%= render @posts %>
</div>
Enter fullscreen mode Exit fullscreen mode

Adding tubro_stream responses

The requests made by the scroll is of type "text/vnd.turbo-stream.html". So, we'll need to handle that type of request.

create a new file named app/views/posts/index.turbo_stream.erb and add this code into it

<%= turbo_stream.append "posts" do %>
  <%= render @posts %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

with this, add id="posts" to the div for turbo to know where to append the new posts.

# posts/index.html.erb
<div
  id="posts"
  data-controller="pagination"
  data-pagination-url-value="<%= posts_path %> "
  data-pagination-page-value="<%= 2 %>">
  <%= render @posts %>
</div>
Enter fullscreen mode Exit fullscreen mode

Let's look at what the controller does now.

.

When to stop?

Obviously, an scroll should be infinite while there are records to fetch, if there are no more records we must not fetch anymore records. With our current implementation our code would send infinite requests as long the user is scrolling to the end. Let's change that.

Inside app/views/products/index.turbo_stream.erb add this

<%= turbo_stream.append "posts" do %>
  <%= render @posts %>

  <% if @posts.page(@page.to_i + 1).out_of_range? %>
    <span class="hidden" data-pagination-target="lastPage"></span>
  <% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Let's add a little bit code into our stimulus controller.

  scroll() {
    if (this.scrollReachedEnd && !this.hasLastPageTarget) {
      this._fetchNewPage()
    }
  }
Enter fullscreen mode Exit fullscreen mode

we check, if there is a lastPage target present, then we stop fetching new page. This would only be true when there are no more pages left.

  <% if @posts.page(@page.to_i + 1).out_of_range? %>
    <span class="hidden" data-pagination-target="lastPage"></span>
  <% end %>
Enter fullscreen mode Exit fullscreen mode

Bonus, add button to load data instead of infinite scroll

Sometimes, you would like only for when a button pressed to load the data, not when the user reaches end of scrolling. Extending the controller is easy, let's perform just that. Inside pagination_controller add these

   static values = {
        url: String,
        page: Number,
        scroll: Boolean
  };


  connect() {
     if(!this.scrollValue) return; // return and don't attach the scroll event listener
     document.addEventListener("scroll", this.scroll);
  }

  async paginate(e) {
    await this._fetchNewPage();
    e.target.blur();
  }
Enter fullscreen mode Exit fullscreen mode

the new scroll boolean will determine if we should infinite-scroll or not. Change the content of app/views/posts/index.html.erb to the following

<div
  data-controller="pagination"
  data-pagination-url-value="<%= posts_url %> "
  data-pagination-page-value="<%= 2 %>"
  data-pagination-scroll-value="false"
  style="overflow-y: scroll">

  <div id="posts">
    <%= render @posts %>
  </div>

  <button data-action="click->pagination#paginate">
    Load more
  </button>

</div>
Enter fullscreen mode Exit fullscreen mode

Now, let's look at the behaviour

Conclusion

We've first created and configured the dependencies and installed them. After that, we introduced our Stimulus pagination controller to aid us to paginate items. Then, we added a target that indicates we are on the last page, to stop the browser from sending infinite useless requests once we are in the last page. Finally, we've added another way to use the controller, that is, by clicking a button the next page shall load.

Thanks for your reading, hope it helps you in a way. Happy coding!

You can also clone the repo here

Links

Oldest comments (3)

Collapse
 
ozovalihasan profile image
Hasan Özovalı • Edited

Thanks a lot for your article.

I noticed one issue. It is requesting the same page multiple times. So, let me share my suggestion to solve this issue.

# posts/index.html.erb
<div
  id="posts"
  data-controller="pagination"
  data-pagination-url-value="<%= posts_path %> "
  data-pagination-page-value="<%= 2 %>"
  data-pagination-request-value="false"
>
  <%= render @posts %>
</div>

Enter fullscreen mode Exit fullscreen mode
// pagination_controller.js
  static values = {
    url: String,
    page: Number,
    request: Boolean,
  };

  scroll() {
    if (!this.requestValue && this.scrollReachedEnd && !this.hasLastPageTarget) {
      this._fetchNewPage();
    }
  }

  async _fetchNewPage() {
    const url = new URL(this.urlValue);
    url.searchParams.set('page', this.pageValue)
    this.requestValue = true;
    await get(url.toString(), {
      responseKind: 'turbo-stream'
    });
    this.requestValue = false;
    this.pageValue +=1;
  }


Enter fullscreen mode Exit fullscreen mode
Collapse
 
12 profile image
VF

How would you go about paginating from top to bottom? Ie a message chat app where you scroll up to see previous messages

Collapse
 
rockwell profile image
Ahmad khattab

I would add another target, that is the messages container. Then, proceed to modify the scroll method to look like this

  async scroll() {
    if (this.chatTarget.scrollTop < 100 && !this.fetching && !this.hasLastPageTarget) {

      this.fetching = true
      await get(this.urlValue, {
        responseKind: "turbo-stream",
        query: {
          page: this.pageValue
        }
      });

      this.pageValue += 1;
      this.fetching = false
    }
  }

Enter fullscreen mode Exit fullscreen mode

PS. you might want to scroll the container when it loads. You can do so in the initialize method

  initialize() {
    this.scrollToBottom();
    super.initialize();
  }

  scrollToBottom() {
   this.inboxTarget.scroll({
        top: this.inboxTarget.scrollHeight,
        behavior: this.messageSent ? "smooth" : "instant",
      });
  }
Enter fullscreen mode Exit fullscreen mode