❗ This is not a tutorial ❗
Yesterday I added an archive route to the Remix app. Visiting the archive route loads the first 15 posts from the WordPress site. Today I’ll figure out how to load more posts after the initial batch of 15.
First I’m going to fix something that’s happening when the site is loaded on a large screens:
There needs to be a constraint on the width of the site header. I’ll try adding an inner wrapper div
to the Header
component:
// app/components/Header.tsx
import { Link } from "@remix-run/react";
import { NavLink } from "@remix-run/react";
export default function Header() {
return (
<header className="bg-slate-500 text-slate-50 text-xl p-4">
<div className="flex justify-between items-center w-full max-w-screen-xl mx-auto">
<h1>
<Link to="/">Zalgorithm</Link>
</h1>
<div>
<NavLink
to="/blog"
className={({ isActive, isPending }) =>
isPending ? "pending" : isActive ? "underline" : ""
}
>
Blog
</NavLink>
</div>
</div>
</header>
);
}
That’s better:
Loading more posts
24 hours later….
(Edit: to see the solution I came up with, skip to https://zalgorithm.com/pagination-take-five-success/.)
Loading more posts with Remix was a challenge. I started looking here: https://remix.run/docs/en/main/discussion/form-vs-fetcher#url-considerations.
URL ConsiderationsThe primary criterion when choosing among these tools is whether you want the URL to change or not:
Remix docs
- URL Change Desired: When navigating or transitioning between pages, or after certain actions like creating or deleting records. This ensures that the user’s browser history accurately reflects their journey through your application.
- Expected Behavior: In many cases, when users hit the back button, they should be taken to the previous page. Other times the history entry may be replaced but the URL change is important nonetheless.
- No URL Change Desired: For actions that don’t significantly change the context or primary content of the current view. This might include updating individual fields or minor data manipulations that don’t warrant a new URL or page reload. This also applies to loading data with fetchers for things like popovers, combo boxes, etc.
(todo: that’s a complex block quote. I probably need to improve the block quote styles on the Remix app.)
My first attempt was to not update the URL when the “Load More” button was clicked. Instead, the plan was to have the next batch of posts append themselves to the previous batch. That eventually resulted in a stack overflow type issue with React useEffect
and useState
hooks. But before things got to that point…
Loading more posts with form.Fetcher
This was seeming like it might work:
// app/routes/blog.archive.tsx
import { json } from "@remix-run/node";
import { useFetcher, 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 async function loader({ request }: { request: Request }) {
const url = new URL(request.url);
const after = url.searchParams.get("lastCursor") || "";
console.log(`after: ${after}`);
const client = createApolloClient();
const response = await client.query({
query: ARCHIVE_POSTS_QUERY,
variables: {
after: after,
},
});
if (response.errors) {
// todo: handle the errors
console.log("An error was returned from the ARCHIVE_POSTS_QUERY");
}
const data = response?.data;
const pageInfo = data?.posts?.pageInfo;
const postEdges = data?.posts?.edges;
const lastCursor = postEdges?.[postEdges.length - 1]?.cursor;
console.log(`lastCursor: ${lastCursor}`);
// don't worry about errors for now
return json({
pageInfo: pageInfo,
postEdges: postEdges,
lastCursor: lastCursor,
});
}
export default function Archive() {
const initialData = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const postEdges = initialData.postEdges;
let lastCursor = initialData.lastCursor;
let pageInfo = initialData.pageInfo;
// if fetcher has data, update lastCursor and pageInfo with new values
if (fetcher.data) {
lastCursor = fetcher.data.lastCursor;
pageInfo = fetcher.data.pageInfo;
}
const currentPostEdges = fetcher.data
? [...postEdges, ...fetcher.data.postEdges]
: postEdges;
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl text-slate-900 mt-3 font-serif font-bold">
Archive
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{currentPostEdges.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 className="py-3">
{pageInfo?.hasNextPage && lastCursor ? (
<fetcher.Form action="?" method="get">
<input type="hidden" name="lastCursor" value={lastCursor} />
<button
type="submit"
className="bg-slate-500 hover:bg-slate-700 text-slate-50 font-bold py-2 px-4 rounded"
>
Load More
</button>
</fetcher.Form>
) : null}
</div>
</div>
);
}
The problem was that it would display the initial batch of posts, then successfully append the next batch of posts after the “Load More” button was clicked. But for the case of there being more than two batches of posts, I couldn’t figure out how to keep appending posts to the list. Loading posts in batches of 2 for testing, the results I was getting were:
- posts 1 and 2 are displayed
- the “Load More” button is clicked
- posts 1, 2, 3, 4 are displayed
- the “Load More” button is clicked
- posts 1, 2, 5, 6 are displayed 🙁
One option for dealing with that was to cache all the posts on the user’s browser, then sending the previous list of posts with each request. That didn’t seem right.
Another possible approach was to use the React useEffect
and useState
hooks. Similar to what’s outlined here: https://github.com/ahmedrizwan/remix-infinite-scroll. That might be the best way of dealing with it, but I gave up on it fairly quickly – ran into some kind of infinite loop in the loading state…
I’m likely missing something obvious.
I moved on to loading each batch of posts on a new route.
Loading more posts on a new route
The query that’s used to load the archive posts accepts an after
variable that’s expected to be set to the value of the cursor
of the previous batch’s last post:
// models/wp_queries.ts
...
export const ARCHIVE_POSTS_QUERY = gql(`
query ArchivePosts($after: String!) {
posts (first: 15, after: $after, where: {orderby: {field:DATE, order: DESC}}) {
pageInfo {
hasNextPage
}
edges {
cursor
node {
id
title
date
slug
excerpt
author {
node {
name
}
}
featuredImage {
node {
altText
sourceUrl
}
}
}
}
}
}
`);
The after
variable can be set to an empty string, so the query works for loading the first batch of posts and all subsequent batches. The pageInfo.hasNextPage
property that’s returned from the query will be set to true
if there are more posts available to load.
If I’m willing to have URLs that look like https://hello.zalgorithm.com/blog/archive/YXJyYXljb25uZWN0aW9uOjc0
, there’s an easy way to implement this:
// app/routes/blog.archive._index.tsx
import { json, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Link, useLoaderData, useRouteError } 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";
// todo: improve this and add og tags
export const meta: MetaFunction = () => {
return [
{ title: "Zalgorithm blog archive" },
{ description: "Zalgorithm blog archive" },
];
};
export const loader = async ({ params }: LoaderFunctionArgs) => {
const cursor = params.lastCursor || "";
const client = createApolloClient();
const response = await client.query({
query: ARCHIVE_POSTS_QUERY,
variables: {
after: cursor,
},
});
if (response.errors || !response?.data) {
throw new Error("An error was returned loading the post archive.");
}
const data = response?.data;
const pageInfo = data?.posts?.pageInfo;
const postEdges = data?.posts?.edges;
const lastCursor = postEdges?.[postEdges.length - 1]?.cursor;
return json({
pageInfo: pageInfo,
postEdges: postEdges,
lastCursor: lastCursor,
});
};
export default function Archive() {
const { pageInfo, postEdges, lastCursor } = useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl text-slate-900 mt-3 font-serif font-bold">
Archive
</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 className="py-3">
{pageInfo?.hasNextPage && lastCursor ? (
<Link
prefetch="intent"
to={`/blog/archive/${lastCursor}`}
className="bg-slate-500 hover:bg-slate-700 text-slate-50 font-bold py-2 px-4 rounded"
>
Next Posts
</Link>
) : null}
</div>
</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>
);
}
The important part is here:
// app/routes/blog.archive._index.tsx
...
{pageInfo?.hasNextPage && lastCursor ? (
<Link
prefetch="intent"
to={`/blog/archive/${lastCursor}`}
className="bg-slate-500 hover:bg-slate-700 text-slate-50 font-bold py-2 px-4 rounded"
>
Next Posts
</Link>
) : null}
...
If pageInfo.hasNextPage
and lastCursor
(the cursor
value of the last post) both exist, add a link to /blog/archive/${lastCursor}
.
The two downsides of this approach are that the cursor
string gets added to the URL’s path and that it seems to require creating a route with identical code to the blog.archive._index.tsx
file at blog.archive.$lastCursor.tsx
. I suspect there are workarounds for both issues. I’ll ignore the duplicate code for now, but want to do something to improve the URLs.
Adding error boundaries
I figured it was time to make a first pass at error handling. I added error boundaries following the method outlined here: https://remix.run/docs/en/main/tutorials/jokes#unexpected-errors.
The idea is to add an ErrorBoundary
component to each route. The useRouteError
hook gets the instance of the error
that is thrown. Setting up the ErrorBoundary
on the root
route requires some fiddling around:
// app/root.tsx
import type { LinksFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useRouteError,
} from "@remix-run/react";
import type { PropsWithChildren } from "react";
import Header from "~/components/Header";
import Footer from "~/components/Footer";
import styles from "./tailwind.css";
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
function Document({ children }: PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<Meta />
<Links />
</head>
<body className="min-h-screen flex flex-col bg-slate-50">
{children}
<LiveReload />
</body>
</html>
);
}
export default function App() {
return (
<Document>
<Header />
<div className="flex-1">
<Outlet />
</div>
<ScrollRestoration />
<Scripts />
<LiveReload />
<Footer />
</Document>
);
}
export function ErrorBoundary() {
const error = useRouteError();
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return (
<Document>
<div className="mx-auto max-w-2xl px-20 py-4 my-10 bg-red-200 border rounded">
<h1>App Error</h1>
<pre>{errorMessage}</pre>
</div>
</Document>
);
}
For other routes, it’s as easy as importing useRouteError
and adding an ErrorBoundary
component to the file:
// any regular route
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>
);
}
I tested this by intentionally triggering errors with throw new Error("Testing Error Boundary");
Testing it on the loader
function for app/routes/blog.$slug.tsx
was useful. Initially, the ErrorBoundary
on that route wasn’t catching the error.
The problem was that I’m dynamically setting the title
and description
meta tags in that component. I I’m understanding the order of operations correctly, Remix runs the loader
function then passes the data that’s returned from the loader to the meta
function, then renders the route’s default component.
To fix the issue, I updated the meta
function to handle the case of its data
argument not being set:
// app/routes/blog.$slug.tsx
...
export const meta: MetaFunction = ({ data }) => {
const post = data as Post;
// Without this condition, errors in the loader function will cause an unhandled
// error in the meta function.
if (!post || !post?.title) {
return [
{ title: "Error Page" },
{ description: "An error occurred while loading the post." },
];
}
const title = post?.title ? post.title : "Simon's blog";
let description = post?.excerpt
? stripHtml(post.excerpt)
: `Read more about ${post.title}`;
description = truncateText(description, 160);
// todo: set BASE_URL as an environental variable so that it doesn't have to be hard coded here:
const url = post?.slug
? `https://hello.zalgorithm.com/blog/${post.slug}`
: "";
let metaTags = [
{ title: title },
{ description: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "website" },
{ property: "og:url", content: url },
];
if (post?.featuredImage?.node?.sourceUrl) {
metaTags.push({
property: "og:image",
content: post.featuredImage.node.sourceUrl,
});
}
return metaTags;
};
...
That was yesterday’s fun. The updated code is live at https://hello.zalgorithm.com/blog/archive.