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" }, {...} ] }
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:
- Create a client-side listener for scroll events
- Establish a determined breakpoint for when the scroll should fire a data fetch
- 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>
);
}
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 (
//...
)
}
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>
)
}
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]);
//...
}
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() {
//...
}
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]);
//...
}
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]);
//...
}
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.
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!