Dead Simple Infinite Scroll with Kaminari and React Waypoint
Note: I am by no means an expert, or even particularly experienced in using Rails and React — I’m writing this mainly for my own reference and to practice talking through my process and work. I’m assuming that if you’re reading this you have familiarity with both Rails and React and, to some extent, Redux. Also, most of the code has been simplified/streamlined for the purpose of explanation — you can view it in it’s full glory here if you dare. If anything is confusing (or just downright wrong), please let me know! I’m still very much a beginner and have a lot to learn 😊
I recently completed a coding bootcamp (almost two months ago now — time flies!) and for my final project, I made a clone of Instagram’s web app with a Rails backend and a React/Redux frontend (you can visit it here if you’re curious — but be warned that it’s not perfect by any stretch of the imagination, including the fact the images are NOT currently optimized, because I’m lazy and haven’t gotten around to it). Instagram uses infinite scroll on both its web app and phone app for the photo feed, loading a few images at a time to ensure that load time of the site isn’t, you know, 10 minutes. Obviously, to make a good clone, I was going to have to implement infinite scroll as well.
When we first started our final projects, the program TAs recommended a couple of React libraries to handle common issues/functionality that we’d be up against. For infinite scrolling, they pointed us to SeatGeek’s React Infinite libary. Understand that at this point, I’d been using React for two weeks, maximum. I looked at the README, got a little overwhelmed, and decided that infinite scroll could wait.
Eventually, though, my photo database grew to the point that it was taking 5–10s to load a feed, which simply wasn’t going to be acceptable when presenting my work to potential employers. I tried implementing React Infinite, but immediately ran into issues. The Infinite component requires that either all elements be the same height or that an array of heights be passed in as a prop. Because I hadn’t introduced any height restrictions/cropping requirements to my photo uploads, I’d have to calculate the height of each element individually and then map that to an array to pass in. Immediately, this struck me as being too much work: I was going to have to take the height of the of the image (which I could, admittedly, just send up from my database) and add it to not only the height of the header and the like field (which aren’t variable), but to the height of the description and any comments that had been made, which are variable and I would have to calculate. And what would happen if someone started typing a long comment field, which grows as the comment does? Would that height have an impact? Dealing with that just sounded annoying and unnecessary. I decided to try and get the scrolling to work with elements of all the same height first, and then tackle the variable height issue when I came to it.
Reader, I tried. Even with elements of all the same height, I simply couldn’t get it to work. Elements disappeared, scrolling acted funny, and elements just wouldn’t load after a certain point. I spoke to classmates who had implemented infinite scroll in their projects, and they said the same thing — React Infinite was buggy and just hadn’t worked for them. To be clear, I don’t think that the problem is with the library itself— which is pretty popular and is used in production by big companies like, y’know, SeatGeek. I think the issue that we had as individual developers with smaller projects (and only a few months experience, if we’re being honest) is that React Infinite is a pretty powerful library with a lot of options, but most of those options just aren’t necessary for a super basic implementation of infinite scroll and ended up getting in the way.
Most of my classmates ended up giving up on React Infinite and using jQuery coupled with some form of pagination to get their infinite scroll to work, a la this tutorial. But using jQuery with React simply shouldn’t be necessary (other than to make AJAX requests, which jQuery still does best, imo), and their code looked bloated and, again, unnecessarily complicated. I figured there had to be a better way.
I decided to start basic, by simply adding pagination to my Rails backend so I could easily ask for a certain number of entries in a request at a time. Doing this with the Kaminari gem is, frankly, dumb easy: a matter of adding it to your Gemfile and running bundle install. Once you do that, your ActiveRecord query in your controller can go from:
Post.all
which gives us all posts, to:
Post.all.page(1)
which gives us the first 25 posts. To change the number of items in a page, you can either change the kaminari.config file (which sets the default for all pages) or, a little easier, add .per():
Post.all.page(1).per(5)
which now gives us the first five posts (I chose five because it seemed like a nice, round number). Of course, I wanted more than just the first page of posts, so rather than hardcoding in the page number, I passed it into my controller as params. So my ActiveRecord query in my controller now looks like this:
Post.all.page(params[:page]).per(5)
while my AJAX call looks like this:
export const fetchPosts = (page) => {
return $.ajax({
method: 'GET',
url: 'api/posts',
data: { page },
});
};
So now, whenever I want to get posts for a user’s feed, I can pass in a page number and get 5 posts at a time, rather than, like, 1000.
Let’s look at how this functions in my Photo Feed Component. In my constructor, I added a post counter to the state, to keep track of what page a user is on:
class PhotoFeed extends React.Component {
constructor(props) {
super(props);
this.state = {
page: 1,
}
...
}
...
}
Then I wrote a function, getPosts, that calls my fetchPosts function and increments the page count:
getPosts() {
this.props.fetchPosts(this.props.currentUser, this.state.page);
this.setState = ({ page: this.state.page += 1 });
}
Then, when the component mounts, I call this function to make an initial request for posts:
componentDidMount() {
this.getPosts();
}
At this point, I had the first page of photos on my feed. I added a button at the bottom and set its onClick to getPosts as well, as a temporary measure. Whenever someone clicked thatbutton, it would fire a request for the next page of photos. New posts were then concatenated onto the existing array of posts and would appear at the bottom of the list. Here’s my reducer for reference:
const defaultState = {
posts = [],
...
}const photoFeedReducer = (state = defaultState, action) => {
switch (action.type) {
case RECEIVE_POSTS:
const posts= state.posts.concat(action.posts);
return { posts };
default:
...
}
}
At this point, I had essentially what I needed for infinite scrolling. The only piece left was having the getPosts function trigger when we hit the bottom of the window, rather than when hitting a button.
This is where React Waypoint comes in. React Waypoint is a library that provides us with a beautiful little component called a Waypoint that detects when we enter it and can call functions onEnter, onLeave, onPositionChange, et cetera. I added a Waypoint at the bottom of my page, pointed the onEnter prop to my getPosts function, and bam! Infinite scroll, just like that. Here’s the final render function for my Photo Feed component, with infinite scroll working:
render() {
return (
<section className="photo-feed">
<ul className="photo-feed-list">
{this.getPostListItems()}
</ul>
<Waypoint
onEnter={this.getPosts}
/>
</section>
);
}
It’s literally that easy — no ugly jQuery, no height calculations, no crazy logic, just an intuitive little library and a super-simple pagination gem. You can find the full code for the entire project here if you’re curious to see how it actually looks in production.
Anyway, that’s how I implemented infinite scroll in Rails and React/Redux. It ended up being a very simple solution that was super easy to set up and was a pretty good learning experience for me. Hopefully this is helpful for anyone else looking to solve the same issue!