❗ This is not a tutorial ❗
Yesterday I added pagination to the app’s Archive route with the useFetcher
hook and the fetcher.Form
component. This works, but using forms to create navigation elements feels weird. It also adds some complexity to the code:
// app/routes/blog.archive.tsx
...
export default function Archive() {
const initialData = useLoaderData<typeof loader>();
let postEdges = initialData.postEdges;
const fetcher = useFetcher();
const fetcherData = fetcher.data as FetcherTypeData;
let currentPageNumber: number;
if (fetcherData && fetcherData?.postEdges) {
postEdges = fetcherData.postEdges;
currentPageNumber = Number(fetcherData?.pageNumber);
}
...
The code is having to distinguish between post data from the initial page load:
const initialData = useLoaderData<typeof loader>();
let postEdges = initialData.postEdges;
and post data that’s generated after a navigation button is clicked:
let currentPageNumber: number;
if (fetcherData && fetcherData?.postEdges) {
postEdges = fetcherData.postEdges;
currentPageNumber = Number(fetcherData?.pageNumber);
}
This seems overly complex.
(Edit: the approach I eventually settled on is outlined here https://zalgorithm.com/pagination-take-five-success/.)
I’m going to try implementing pagination with Link
components and see if that simplifies things.
Link
based pagination
Checkout a new branch to avoid losing my old work:
$ git checkout -b link_based_pagination
Make a copy of blog.archive.tsx
to keep it as a reference:
$ cp app/routes/blog.archive.tsx app/routes/blog.archiveBak.tsx
… it’s 7:30 on a Saturday night. I’ll work on this until 9.
The errorBoundary
component is working:
I wasn’t exporting the loader
function.
With that fixed, the blog.archive
route’s code is more readable than yesterday’s version:
// app/routes/blog.archive.tsx
import { json } from "@remix-run/node";
import { Link, useLoaderData, useRouteError } from "@remix-run/react";
import { Maybe } from "graphql/jsutils/Maybe";
import { createApolloClient } from "lib/createApolloClient";
import { ARCHIVE_CURSORS_QUERY } from "~/models/wp_queries";
import type { RootQueryToPostConnectionEdge } from "~/graphql/__generated__/graphql";
interface Page {
pageNumber: number;
lastCursor: string;
}
export const loader = async () => {
const client = createApolloClient();
const response = await client.query({
query: ARCHIVE_CURSORS_QUERY,
variables: {
after: "",
},
});
if (response.errors || !response?.data?.posts?.edges) {
throw new Error("Unable to load post details.");
}
const chunkSize = Number(process.env?.ARCHIVE_CHUNK_SIZE) || 15;
const cursorEdges = response.data.posts.edges;
const pages = cursorEdges.reduce(
(
acc: { lastCursor: Maybe<string | null>; pageNumber: number }[],
edge: RootQueryToPostConnectionEdge,
index: number
) => {
if ((index + 1) % chunkSize === 0) {
acc.push({
lastCursor: edge.cursor,
pageNumber: acc.length + 1,
});
}
return acc;
},
[]
);
// handle the case of the chunk size being a multiple of the total number of posts.
if (cursorEdges.length % chunkSize === 0) {
pages.pop();
}
// add a first page object (lastCursor: "")
pages.unshift({
lastCursor: "",
pageNumber: 0,
});
return json({ pages: pages });
};
export default function Archive() {
const { pages } = useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl py-3">
Working on a new archive page for the blog
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"></div>
{pages.map((page: Page) => (
<Link
to={`blog/archive/${page.pageNumber}`}
className="px-3 py-2 mx-3 hover:underline text-sky-700"
key={page.pageNumber}
>
{page.pageNumber + 1}
</Link>
))}
</div>
);
}
export function ErrorBoundary() {
const error = useRouteError();
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return (
<div className="mx-auto max-w-3xl px-5 py-4 my-10 bg-red-200 border-2 border-red-700 rounded break-all">
<h1>App Error</h1>
<pre>{errorMessage}</pre>
</div>
);
}
Clicking a link returns an error as expected. The error’s caught by the root
route’s ErrorBoundary
:
One thing to note is that the back end code is using 0-based indexing, so the first page has pageNumber: 0
. The user interface is using normal human numbers, so the first page is considered to be page number 1. I’ll sort that out.
Create a blog.archive.pageNumber
route
Saturday night 🙂
The naming seems off, but:
$ touch app/routes/blog.archive.\$page.tsx
// app/routes/blog.archive.$page.tsx
export default function ArchiveBlogPage() {
return (
<div>
<h2>In the ArchiveBlogPage Component</h2>
</div>
);
}
With that in place, I’m still getting an error clicking the pagination links. Looking closer at the URL, the issue is obvious: http://localhost:3000/blog/archive/blog/archive/2
.
The Link
component’s to
property is relative to the current page. This should fix the issue:
// app/routes/blog.archive.tsx
...
export default function Archive() {
const { pages } = useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl py-3">
Working on a new archive page for the blog
</h2>
<Outlet />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"></div>
{pages.map((page: Page) => (
<Link
to={String(page.pageNumber)}
className="px-3 py-2 mx-3 hover:underline text-sky-700"
key={page.pageNumber}
>
{page.pageNumber + 1}
</Link>
))}
</div>
);
}
...
Awesome 🙂
For now I’m ignoring the issue that the first batch of posts should be rendered when /blog/archive
is visited.
I’ll try loading posts on the new route:
// app/routes/blog.archive.$page.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { createApolloClient } from "lib/createApolloClient";
import { ARCHIVE_POSTS_QUERY } from "~/models/wp_queries";
import type { PostConnectionEdge } from "~/graphql/__generated__/graphql";
import PostExcerptCard from "~/components/PostExcerptCard";
export const loader = async ({ request }: LoaderFunctionArgs) => {
let { searchParams } = new URL(request.url);
// todo: need to handle the case cursor not being set
const cursor = searchParams.get("cursor");
const client = createApolloClient();
const response = await client.query({
query: ARCHIVE_POSTS_QUERY,
variables: {
after: cursor,
},
});
if (response.errors || !response?.data?.posts?.edges) {
throw new Error("An error was returned loading the posts");
}
const postEdges = response.data.posts.edges;
return json({ postEdges: postEdges });
};
export default function ArchiveBlogPage() {
const { postEdges } = useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl py-3">
Working on a new archive page for the blog
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{postEdges.map((edge: PostConnectionEdge) => (
<PostExcerptCard
title={edge.node?.title}
date={edge.node?.date}
featuredImage={edge.node?.featuredImage?.node?.sourceUrl}
excerpt={edge.node?.excerpt}
authorName={edge.node?.author?.node?.name}
slug={edge.node?.slug}
key={edge.node.id}
/>
))}
</div>
</div>
);
}
That works:
Setting this up, I realized that the page.pageNumber
part of the path that’s being passed to blog.archive.$page.tsx
doesn’t do anything. That page needs the value of the cursor
, not the page number. I’m dealing with that now by passing the cursor
as a query param: http://localhost:3000/blog/archive/1?cursor=YXJyYXljb25uZWN0aW9uOjExOA==
. I don’t see a great workaround for that.
A bigger, but solvable issue is to populate the /blog/archive
route with a default batch of posts:
I’ll deal with that tomorrow.
Edit:
I posted today’s and yesterday’s code to ChatGPT (4)
me:
Would it be possible to use Link
components instead of form.Fetcher
but have the results rendered on /blog/archive
without requiring a full page refresh (only update the PostExcerptCard
section when a link to, for example /blog/archive?cursor=abcdefg
was clicked?) I guess what I’m asking is if I can get similar functionality from a Link
component as the code I provided to you is getting from the form.Fetcher
component.
ChatGPT:
Yes, you can achieve similar functionality using Link
components instead of fetcher.Form
for navigation without a full page refresh by leveraging Remix’s data loading and caching capabilities, but it requires a slightly different approach. Here’s how you can modify your implementation to use Link
components and still update only the relevant parts of your page:
- Optimized Data Fetching with Loaders: Instead of fetching all data within a single loader, you can use Remix’s nested routing capabilities. You can have a parent route (
blog.archive.tsx
) handle the pagination logic and a child route (e.g.,blog.archive.$cursor.tsx
) handle the fetching and display of posts for a given cursor. This way, when you navigate usingLink
components, Remix will only re-fetch the data necessary for the child route, updating only the part of the component tree that depends on this data. - Link Component for Navigation: You can use the
Link
component to navigate between different cursors without causing a full page refresh. TheLink
should point to the same archive route but include different query parameters (or URL segments if you adjust your route structure) for each page, like/blog/archive?cursor=nextCursorValue
.
Here’s a high-level overview of how you can adjust your code:
Parent Route (blog.archive.tsx
):
Keep the pagination logic here but remove post fetching. Use Link
components for navigation:
{pages.map((page: Page) => (
<Link
to={`?cursor=${page.lastCursor}`}
className="px-3 py-2 mx-3 hover:underline text-sky-700"
key={page.pageNumber}
>
{page.pageNumber + 1}
</Link>
))}
Child Route (blog.archive.$cursor.tsx
or similar):
This child component would handle fetching and displaying posts for the given cursor. The loader in this file would be responsible for fetching the posts based on the cursor:
// Adjust the loader to fetch based on the cursor from the URL.
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const cursor = url.searchParams.get("cursor") || "";
const client = createApolloClient();
const response = await client.query({
query: ARCHIVE_POSTS_QUERY,
variables: {
after: cursor,
},
});
if (response.errors || !response.data.posts.edges) {
throw new Error("An error occurred while loading the posts.");
}
return json({ postEdges: response.data.posts.edges });
};
And then render the posts in the component:
// Use the fetched post edges to render the PostExcerptCards.
const { postEdges } = useLoaderData<typeof loader>();
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{postEdges.map((edge: PostConnectionEdge) => (
<PostExcerptCard
/* props */
/>
))}
</div>
);
That’s tomorrow’s project 🙂