Infinite Scroll with Remix Run

Phil Tenteromano - Feb 16 '22 - - Dev Community

Introduction

Remix Run has landed and has created a lot of buzz in the the javascript framework world. After digging in, it motivated me to try it out by starting a web project that I've put to the side for far too long. One of the main features of this project is viewing a list of an arbitrary amount of photos, fed from a Rails API. This means infinite scroll. A video of the final result is at the bottom.

Traditionally, with client side react, we'd just listen to some breakpoint on scroll or use a virtualization tool package like react-virtual. Then we fire off our own methods, merge the state, and repeat.

Remix Run however, handles its components both on the server (SSR) and in the browser. When I was first trying out infinite scroll, I found that I was fighting against the loader and action conventions. It felt wrong, and I knew there had to be a better way. Only once I better understood that Remix's url routes are also it's api routes, I began to piece it together.

Reader note: This infinite scroll is built without any other dependencies (the scroll-breakpoint method). The styling is done with tailwindcss, which I think pairs really well with Remix. The Rails API always returns a json response. In this case the fetchPhotos method returns a response in the format:



{ photos: [ { url: "some_url", pid: "12345" }, {...} ] }


Enter fullscreen mode Exit fullscreen mode

The fetchPhotos abstraction can be replaced with any API or database call for data of your choosing.

We'll solve this problem in three steps:

  1. Create a client-side listener for scroll events
  2. Establish a determined breakpoint for when the scroll should fire a data fetch
  3. Merge the newly fetched data into the current data.

All while allowing this process to repeat until there is no more data to be fetched. Let's start!

Leveraging The Scroll

I created a component at app/routes/photos/_index.tsx. In Remix, this is accessible in the browser at /photos. The Rails API I'm using uses a page param to get photos in increments of 10. The initial code to load the page looks like this:



import { useLoaderData, LoaderFunction } from "remix";
import { fetchPhotos } from "~/utils/api/restful";
import type { PhotoHash } from "~/utils/api/types";

export const loader: LoaderFunction = async ({ request }) => {
  // Pass a page number to the Rails API
  const resp = await fetchPhotos(1);
  return resp.photos;
};

export default function Photos() {
  const photos = useLoaderData<PhotoHash[]>();

  return (
    <div className="container mx-auto space-y-2 md:space-y-0 md:gap-2 md:grid md:grid-cols-2 py-4">
      {photos.map((photo) => {
        return (
          <div key={photo.pid} className="w-full border-green-200 h-52">
            <img
              className="mx-auto object-center object-cover h-52 rounded hover:shadow-2xl"
              src={photo.url}
              alt={`photo-${photo.pid}`}
            />
          </div>
        );
      })}
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

But this only gets the first page of photos! fetchPhotos(1) is just a call to the Rails server with a first page argument. When the page loads, the loader will fetch the photos and render them into the jsx. The problem remains, how do we dynamically fetch more photos during scroll. Keep in mind that Remix also runs this code on the server where there is no window or document, etc. So we need to rethink our approach!

What we need now is a client side listener for scroll events and the browser height. We'll need some React for that:



import { useEffect, useState } from "react";
// ...

export default function Photos() {
  const photos = useLoaderData<PhotoHash[]>();
  const [scrollPosition, setScrollPosition] = useState(0);
  const [clientHeight, setClientHeight] = useState(0);

  // Add Listeners to scroll and client resize
  useEffect(() => {
    const scrollListener = () => {
      setClientHeight(window.innerHeight);
      setScrollPosition(window.scrollY);
    };

    // Avoid running during SSR
    if (typeof window !== "undefined") {
      window.addEventListener("scroll", scrollListener);
    }

    // Clean up
    return () => {
      if (typeof window !== "undefined") {
        window.removeEventListener("scroll", scrollListener);
      }
    };
  }, []);

return (
  //...
 )
}


Enter fullscreen mode Exit fullscreen mode

Awesome, now if you add a console.log inside the scrollListener you'll see it fire very frequently whenever you scroll, which is a great start. We also check for the window, ensuring the callback only gets attached and removed when the component is rendered on the client. Now we can track where we are during scrolling.

The next step now is to figure out when we want to fire our call to fetch more photos. To do that, we also need to find the height of the parent div that contains all the photos. We'll need a useCallback listener to attach to that parent:



import { useEffect, useState, useCallback } from "react";
//...

export default function Photos() {
  //...
  // We won't care about height until a client-side render
  const [height, setHeight] = useState(null);

  // Set height of the parent container whenever photos are loaded
  const divHeight = useCallback(
    (node) => {
      if (node !== null) {
        setHeight(node.getBoundingClientRect().height);
      }
    },
    [photos.length]
  );

  //...

  return (
    <div ref={divHeight} ...>
      //...
    </div>
  )
}


Enter fullscreen mode Exit fullscreen mode

Attaching this method as the ref of the parent div allows us to set the height into our components state. Furthermore, when we create a dependency on photos.length we are ensuring the container's height is set properly every time the number of photos change, which would alter the height of that container div.

Now we just need to know when we are firing our API call. We can do a little custom algebra here. Note: This all varies based upon your layout, the media size (mobile, large desktops, etc). YMMV! At the very least, the parent div should be scrollable from the initial load for this to work properly.



//...
export default Photos() {
  const [shouldFetch, setShouldFetch] = useState(true);
  //...

  // Listen on scrolls. Fire on some self-described breakpoint
  useEffect(() => {
    if (!shouldFetch || !height) return;
    if (clientHeight + scrollPosition + 100 < height) return;
    if (fetcher.state == "loading") return;
    console.log("SHOULD BE FETCHING!");

    setShouldFetch(false);
  }, [clientHeight, scrollPosition]);
  //...
}


Enter fullscreen mode Exit fullscreen mode

This check clientHeight + scrollPosition + 100 < height is the scroll check, and is completely customizable in your own infinite scroll. This fires with the clientHeight and scrollPosition dependencies so once the criteria is met, we get through the Guard clauses and reach that console.log statement. Update: as of a newer version of remix, I've added the next condition fetcher.state == "loading". We need this to ensure we only refetch when the fetcher is not already fetching! Try it yourself, the log should fire once and only once - preventing further calls due to setShouldFetch(false).

Remix Conventions

So now we want to replace that console.log with a real API call and merge them into our photos; allowing this to repeat for as long as there are pages with photo data. But the question with Remix is – where should we call our API fetch from?

Remix's routes are also its API routes. This means that if you want to fetch data, well, you should hit your own routes. In this case the route is itself (/photos), just with a different page parameter (to be sent to our Rails API). Let's look at the loader for a second, and add some url query parameter for the page:



//...
// Pull page down from the loader's api request
const getPage = (searchParams: URLSearchParams) =>
  Number(searchParams.get("page") || "1");

export const loader: LoaderFunction = async ({ request }) => {
  const page = getPage(new URL(request.url).searchParams);
  const resp = await fetchPhotos(page);
  return resp.photos;
};

export default function Photos() {
  //...
}


Enter fullscreen mode Exit fullscreen mode

By parsing the page URL parameter, we can now make this loader a little bit more dynamic. Hopefully you see where I'm going with this – to fetch more data, we just continuously have to load our data from this route, just with another parameter.

How does Remix do this? There's a nice little hook called useFetcher – you can see the documentation on it here. Most of the time, loader and action are all you need, but useFetcher gives us some fantastic flexibility. Specifically, we're gonna leverage our already built loader, combined with fetcher's load method. Let's call it when we hit our scrolling criteria:



import { useLoaderData, LoaderFunction, useFetcher } from "remix";
//... 

export default function Photos() {
  // Start with two because 1 was pre-loaded
  const [page, setPage] = useState(2); 
  const fetcher = useFetcher();
  //...

  // Listen on scrolls. Fire on some self-described breakpoint
  useEffect(() => {
    if (!shouldFetch || !height) return;
    if (clientHeight + scrollPosition + 100 < height) return;
    if (fetcher.state == "loading") return;
    fetcher.load(`/photos?index&page=${page}`);

    setShouldFetch(false);
  }, [clientHeight, scrollPosition, fetcher.state]);
  //...
}


Enter fullscreen mode Exit fullscreen mode

Alright, we are leveraging the fetcher to load the next page (2) when we reach close to the bottom of the parent div. The fetcher calls the loader from our route, and applies a page query parameter. Keep in mind, we add the index parameter because this route is an index route; refer to the useFetcher docs for greater detail. So now we need to pull the newly fetched photos out and add them to the UI. And what about continuously grabbing more data like "Infinite Scroll" promises? Well, the fetcher is stateful, so we'll leverage this in a new, final useEffect:



//...
export default function Photos() {
  const initialPhotos = useLoaderData<PhotoHash[]>();
  const [photos, setPhotos] = useState(initialPhotos);
  //...

  // Merge photos, increment page, and allow fetching again
  useEffect(() => {
    // Discontinue API calls if the last page has been reached
    if (fetcher.data && fetcher.data.length === 0) {
      setShouldFetch(false);
      return;
    }

    // Photos contain data, merge them and allow the possiblity of another fetch
    if (fetcher.data && fetcher.data.length > 0) {
      setPhotos((prevPhotos: PhotoHash[]) => [...prevPhotos, ...fetcher.data]);
      setPage((page: number) => page + 1);
      setShouldFetch(true);
    }
  }, [fetcher.data]);
  //...
}


Enter fullscreen mode Exit fullscreen mode

You can read about fetcher.data here; it's pretty much just the result of .load(), which in our case is an array of photos. In this useEffect, we depend on fetcher.data, and call this whenever that has changed - which directly corresponds to the previous useEffect where fetcher.load(...) is called.

Let's dive in to this new useEffect. When the photos array is 0 we stop fetching all together, meaning we've reached the end of all possible photos (your data source might be different). This prevents the API from being spammed, or making unnecessary calls.

But when there is photo data in the array, we merge those newly fetched photos with our current photos. Then we increment the page number by one, and finally we set our shouldFetch to true, signaling that we want to get the next page when the user has scrolled enough.

Behind the scenes, once the new photos are merged, the container div will grow. This causes a re-size of the height variable. When a user scrolls, the useEffect with fetcher.load() will continue to be triggered but the breakpoint has now changed, forcing the Guard clause to return early. Only when the user scrolls and hits that newly calculated breakpoint will the Guard clauses be bypassed, and fetcher.load() will be called again with the next page number. This will eventually update fetcher.data, causing another photo merge. This process will repeat as the user scrolls, until there is no more data being received from the API.

Below is a little preview of how it works in my browser, along with my console output. You can also view the code for the whole file at this gist.

Browser infinite scroll, loading on scroll

Thank you all for reading! I hope this helps you deliver a dependency free, Remix-like infinite scroll experience. Let me know if you have any feedback or suggestions in the comments below. Good luck and have fun with Remix!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .