Remix – Infinite Scroll Loading
A quick tutorial on how to do infinite scroll loading in Remix. The constraints are:
- The first page should be prefetched before initial render (SSR)
- Subsequent pages should be loaded when the scrollbar reaches the bottom of the page using
useFetcher
So let's begin.
Create Project
Create a Remix project using CLI.
npx create-remix@latest
Follow through with the prompts and select TypeScript
when prompted with language selection (since the code in this article is all TypeScript). Once done, the folder structure should looks something like:
Mock API
Create a file app/api.server.ts
with the following content.
export type Data = { id: number; thumb: string };
export type ItemsResponse = { data: Data[]; page: number };
export const fetchItems = async (query: {
page: number;
}): Promise<ItemsResponse> => {
const start = query.page * 30;
const items = Array.from({ length: 30 }, (_, i) => i + start).map((id) => ({
id,
thumb: `https://picsum.photos/200?${id}`, // Mocked placeholder images
}));
// Fake delayed response
await new Promise((r) => setTimeout(r, 500));
return Promise.resolve({
data: items,
page: query.page,
});
};
The fetchItems
method returns a resolved promise of mocked paginated data containing 30 items (for each page). A single item in the list contains an id
and a thumb
url.
Loader
In app/routes/index.tsx
, create a loader function – which simply fetches the list of items against the queried page.
export const loader: LoaderFunction = async (remixContext) => {
const url = new URL(remixContext.request.url);
const page = url.searchParams.get("page") || 0;
const items = await fetchItems({
page: Number(page),
});
return items;
};
Load Initial Items
This step is optional but to have a fancy looking grid of images, create app/styles/global.css
.
/* Items Grid */
.items-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 0.5fr));
gap: 4px;
}
.item {
height: 100%;
width: 100%;
border-radius: 6px;
}
And then link the stylesheet in app/root.tsx
.
import styles from "~/styles/global.css";
export function links() {
return [{ rel: "stylesheet", href: styles }];
}
Back in our index component - get the initial items from our mocked server using useLoaderData
.
export default function Index() {
const initialItems = useLoaderData<ItemsResponse>();
const [items, setItems] = useState<Data[]>(initialItems.data);
return (
<div className="items-container">
{items.map((item) => (
<img key={item.id} className="item" src={item.thumb} />
))}
</div>
);
}
With this we should have the grid of images.
Fetching More Pages
To manually fetch more pages, we need to leverage Remix's Fetcher. This can be done in three parts.
- Add
useFetcher
hook in the component - Create
useEffect
to monitor data changes - Create a method to programmatically fetch next page
const fetcher = useFetcher<ItemsResponse>();
// An effect for appending data to items state
useEffect(() => {
if (!fetcher.data || fetcher.state === "loading") {
return;
}
// If we have new data - append it
if (fetcher.data) {
const newItems = fetcher.data.data;
setItems((prevAssets) => [...prevAssets, ...newItems]);
}
}, [fetcher.data]);
// A method for fetching next page
const loadNext = () => {
const page = fetcher.data ? fetcher.data.page + 1 : initialItems.page + 1;
const query = `?index&page=${page}`;
fetcher.load(query); // this call will trigger the loader with a new query
};
Note: There's a need to specify the route in query as well i.e. the index
route – otherwise the index loader is never triggered.
Infinite Scroller
We need a component that listens for scroll events and calls loadNext
when the scrollbar reaches page bottom.
const InfiniteScroller = (props: {
children: any;
loading: boolean;
loadNext: () => void;
}) => {
const { children, loading, loadNext } = props;
const scrollListener = useRef(loadNext);
useEffect(() => {
scrollListener.current = loadNext;
}, [loadNext]);
const onScroll = () => {
const documentHeight = document.documentElement.scrollHeight;
const scrollDifference = Math.floor(window.innerHeight + window.scrollY);
const scrollEnded = documentHeight == scrollDifference;
if (scrollEnded && !loading) {
scrollListener.current();
}
};
useEffect(() => {
if (typeof window !== "undefined") {
window.addEventListener("scroll", onScroll);
}
return () => {
window.removeEventListener("scroll", onScroll);
};
}, []);
return <>{children}</>;
};
There's a small gotcha here - to always have the latest definition of the loadNext
function, we need to wrap it in a ref using useRef
.
Combining Everything!
If we combine it all - this is what the Index component should look like.
export default function Index() {
const initialItems = useLoaderData<ItemsResponse>();
const fetcher = useFetcher<ItemsResponse>();
const [items, setItems] = useState<Data[]>(initialItems.data);
// An effect for appending data to items state
useEffect(() => {
if (!fetcher.data || fetcher.state === "loading") {
return;
}
if (fetcher.data) {
const newItems = fetcher.data.data;
setItems((prevAssets) => [...prevAssets, ...newItems]);
}
}, [fetcher.data]);
return (
<InfiniteScroller
loadNext={() => {
const page = fetcher.data
? fetcher.data.page + 1
: initialItems.page + 1;
const query = `?index&page=${page}`;
fetcher.load(query);
}}
loading={fetcher.state === "loading"}
>
<div>
{/* Items Grid */}
<div className="items-container">
{items.map((item) => (
<img key={item.id} className="item" src={item.thumb} />
))}
</div>
{/* Loader */}
</div>
</InfiniteScroller>
);
}
And voila!
You can find the complete source here.