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 --
- https://devtools.tech/questions/all/?page=1
- https://devtools.tech/questions/all/?page=2
- https://devtools.tech/questions/all/?page=3
- https://devtools.tech/questions/all/?page=4
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.
- 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
isdisabled
. - If we have the
questions
array but it is empty thennext
isdisabled
.
- 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.
- 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.