Add Pagination to Remix Run

Saturday, January 29, 2022

In this blog post, we are going to see how can we add pagination to Remix using core web building blocks.

How does basic pagination work?

The idea behind pagination is that we only fetch the items of the current page where the value of the current page is tracked via the URL. For example --

Here each page is rendered using SSR and showcases a particular section of the questions. This helps in creating shareable URLs that our end users can further use to share among themselves.

Data Fetching

To implement, we first need to understand how data fetching works in Remix.

Loader

In most simple terms, each route can define a loader function that is invoked on the server before rendering to provide data to the route. It handles all the GET requests for that route. If you are coming from the Next.js world then it is similar to getServerSideProps.

// Route: /questions/all | File: questions.all.js
import { useLoaderData } from "remix";
import { getQuestions } from "./data/questions.server.js";

export const loader = async () => {
  // Data fetching can happen here
  // getQuestions talks to our API and returns an array of questions
  const questions = await getQuestions();

  return {
    questions,
  };
};

export const QuestionsRoute = () => {
  const { questions } = useLoaderData();

  // can use questions here
};

In the above code snippet, we export the loader function where we make a call to our API to fetch all questions. We can now access the response from the loader in the component using a custom hook provided by Remix called useLoaderData.

Rendering Logic

We have data from the loader now we can render the list of questions.

export const QuestionsRoute = () => {
  const { questions } = useLoaderData();

  if (!questions || !questions.length) {
    return <div>Empty...</div>;
  }

  return (
    <ul>
      {questions.map((question) => (
        <li key={question.id}>{question.title}</li>
      ))}
    </ul>
  );
};

Handling Navigation

Let us assume that our loader data provides the value of the currentPage. We can use this data to show the previous and next buttons. There could be multiple ways to do this.

  1. Simple Link component
import { Link } from 'remix';

export const QuestionsRoute = () => {
  const { questions, currentPage } = useLoaderData();
  const pagination = useMemo(() => {
    const previousPage = currentPage - 1 || 1;
    const nextPage = currentPage + 1;

    const pagination = {
      previous: {
        disabled: previousPage <= 1,
        link: `/questions/all/?page=$${previousPage}`
      },
      next: {
        disabled: questions && !questions.length, // or some empty state indicator
        link: `/questions/all/?page=$${nextPage}`
      }
    }

    return pagination;
  }, [currentPage]);

  ...

  return (
    <>
      ...
      <div className="navigation">
        <Link to={pagination.previous.link} className={pagination.previous.disabled ? 'disabled': ''}>
          Previous
        </Link>
        <Link to={pagination.next.link} className={pagination.next.disabled ? 'disabled': ''}>
          Next
        </Link>
      </div>
    </>
  );
};

In the above code snippet, we compute the UI state and link for previous and next using the currentPage value.

  • If the current page is the first page then previous is disabled.
  • If we have the questions array but it is empty then next is disabled.
  1. Navigating Programmatically
import { useNavigate } from 'remix';

export const QuestionsRoute = () => {
  const { questions, currentPage } = useLoaderData();
  const navigate = useNavigate();

  const handleNavigation = (e) => {
    const { target } = e;
    const isForwardRequest = target.getAttribute('data-nav-operation') === 'next';
    const offset = isForwardRequest ? 1 : -1;

    navigate(`/questions/all/?page=${currentPage + offset}`)
  };

  ...

  return (
    <>
      ...
      <div className="navigation">
        <button onClick={handleNavigation} disabled={!currentPage} data-nav-operation="previous">
          Previous
        </button>
        <button onClick={handleNavigation} disabled={questions && !questions.length} data-nav-operation="next">
          Next
        </button>
      </div>
    </>
  );
};

In the above code snippet, we handle the navigation programmatically i.e. rather than rendering Links, we render buttons and on clicking of the buttons, we compute the next URL.

  1. Reading SearchParams Client Side
import { useSearchParams } from 'remix';

export const QuestionsRoute = () => {
  const [searchParams, setSearchParams] = useSearchParams();
  const currentPage = parseInt(searchParams.get("page") || 0, 10);

  const handleClick = (e) => {
    const { target } = e;
    const operation = target.getAttribute("data-nav-operation");
    const offset = operation === "next" ? 1 : -1;

    setSearchParams({ page: currentPage + offset });
  };

  ...

  return (
    <>
      ...
      <div className="navigation">
        <button onClick={handleNavigation} disabled={!currentPage} data-nav-operation="previous">
          Previous
        </button>
        <button onClick={handleNavigation} disabled={questions && !questions.length} data-nav-operation="next">
          Next
        </button>
      </div>
    </>
  );
};

In this approach, it doesn't matter if we get the currentPage from the server. We read the value directly from the URL and update it based on user action. Due to URL updates, the remix will re-render the components and re-run the loaders.

You can use any approach that you feel is more apt for you!

Handling Query Params

The loader function gets multiple parameters like params, context, and request. We are most interested in the request parameter. It is a Fetch Request instance with information about the current request. We are going to use the request parameter to access the page query param and modify the data fetching logic.

export const loader = async ({ request }) => {
  // Current url: devtools.tech/questions/all?page=2
  const url = new URL(request.url);
  const page = url.searchParams.get("page") || 1;
  const questions = await getQuestions({
    page,
  });

  return {
    questions,
    currentPage: page,
  };
};

In the above code snippet, we create a new URL() object bypassing the current request URL. We access the current page from searchParams and pass that to our getQuestions function. Now, our Backend API reads this parameter and returns the data for that particular page.

Everything in Action

Unsure about your interview prep? Practice Mock Interviews with us!

Book Your Slot Now