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.

  1. Add useFetcher hook in the component
  2. Create useEffect to monitor data changes
  3. 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.