Pagination (take three)

❗ 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:

  1. 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 using Link 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.
  2. Link Component for Navigation: You can use the Link component to navigate between different cursors without causing a full page refresh. The Link 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 🙂