Remix で通常のページングにする

前回無限ローディングにしたら良くなさそうなコードになったので普通にページングにした。

app/routes/_index.tsx

シンプルに useLoaderData だけで済むようにした。
現状、投稿後に勝手にページ遷移はしないが、最後のページを見ている場合は自動でページ遷移させたい。

あと最初に開くページを最後のページにしたい。

import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";
import { Comment } from "~/components/Comment";
import { CommentForm, CommentFormAction } from "~/components/CommentForm";
import { listPosts } from "~/repository/posts";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    {
      name: "description",
      content: "Welcome to Remix on Cloudflare!",
    },
  ];
};

export const loader = async ({ context, request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const page = url.searchParams.get("page");
  return listPosts(context.cloudflare.env.DB, page ? Number(page) : 1);
};

export const action = CommentFormAction;

export default function Index() {
  const comments = useLoaderData<typeof loader>();
  return (
    <div className="px-4 py-16 mx-auto prose flex flex-col gap-8">
      <ul>
        {comments.comments.map((c) => (
          <li id={`comment-${c.id}`} key={c.id}>
            <span>{c.id}</span>
            <Comment body={c.body} created={c.created} />
          </li>
        ))}
      </ul>
      <CommentForm />
      <nav>
        <p>Total: {comments.total}</p>
        <ul>
          {Array.from({ length: comments.pages }).map((_, index) => (
            <li key={`page-${index + 1}`}>
              <a href={`?page=${index + 1}`}>{index + 1}</a>
            </li>
          ))}
        </ul>
      </nav>
    </div>
  );
}

app/components/CommentForm.tsx

useFetcher を使い、投稿後にページ遷移が発生しないようにした。
前回どうしてこうしなかったのか覚えてない。

import { getTextareaProps, useForm } from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import type { ActionFunctionArgs } from "@remix-run/cloudflare";
import { useFetcher } from "@remix-run/react";
import { useEffect } from "react";
import { z } from "zod";
import { postPost } from "~/repository/posts";

const schema = z.object({
  body: z.preprocess((value) => {
    if (typeof value !== "string") return value;
    return value.trim() === "" ? undefined : value;
  }, z.string().max(1000).trim()),
});

export async function CommentFormAction({
  request,
  context,
}: ActionFunctionArgs) {
  const formData = await request.formData();
  return postPost(context.cloudflare.env.DB, formData);
}

export function CommentForm() {
  const fetcher = useFetcher<typeof CommentFormAction>();
  const [form, fields] = useForm({
    constraint: getZodConstraint(schema),
    shouldValidate: "onBlur",
    shouldRevalidate: "onInput",
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
  });

  // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data) {
      form.reset();
    }
  }, [fetcher.state, fetcher.data]);

  const { key: textAreaKey, ...textAreaRestProps } = getTextareaProps(
    fields.body,
  );
  return (
    <fetcher.Form
      method="post"
      onSubmit={form.onSubmit}
      id={form.id}
      aria-invalid={form.errors ? true : undefined}
      aria-describedby={form.errors ? form.errorId : undefined}
      className="flex flex-col gap-2"
    >
      <div id={form.errorId}>{form.errors}</div>
      <textarea
        key={textAreaKey}
        {...textAreaRestProps}
        maxLength={undefined}
        rows={6}
        className="rounded aria-[invalid]:border-red-500 aria-[invalid]:bg-red-100"
      />
      <span id={fields.body.errorId} className="text-red-500">
        {fields.body.errors}
      </span>
      <button
        type="submit"
        className="mx-auto w-96 max-w-full p-2 bg-sky-700 rounded text-white font-bold"
      >
        投稿
      </button>
    </fetcher.Form>
  );
}

app/repository/posts.ts

ページ数を出すための関数を追加し、普通に offset でページごとに取得するようにした。

import { parseWithZod } from "@conform-to/zod";
import { z } from "zod";

const postSchema = z.object({
  id: z.number(),
  body: z.string(),
  created: z.string(),
});
export type Post = z.infer<typeof postSchema>;

const postsSchema = z.array(postSchema);
const LIMIT = 10;

const pageSchema = z.object({
  total: z.number(),
});
async function getTotalPosts(db: D1Database) {
  const stmt = db.prepare("SELECT COUNT(*) AS `total` FROM `posts`");
  const result = await stmt.first();
  return pageSchema.parse(result);
}

export async function listPosts(db: D1Database, page: number) {
  if (page < 1) throw new Error("invalid page");

  const { total } = await getTotalPosts(db);
  const pages = Math.ceil(total / LIMIT);
  if (page > pages) throw new Error("invalid page");

  const stmt = db
    .prepare("SELECT * FROM `posts` ORDER BY id LIMIT ? OFFSET ?")
    .bind(LIMIT, (page - 1) * LIMIT);
  const { results } = await stmt.all();
  const comments = postsSchema.parse(results);
  return {
    total,
    pages,
    comments,
  };
}

const postPostSchema = z.object({
  body: z.preprocess((value) => {
    if (typeof value !== "string") return value;
    return value.trim() === "" ? undefined : value;
  }, z.string().max(1000).trim()),
});
export async function postPost(db: D1Database, formData: FormData) {
  const submission = parseWithZod(formData, { schema: postPostSchema });

  if (submission.status !== "success") {
    return submission.reply();
  }

  try {
    const result = await db
      .prepare("INSERT INTO `posts` (body) VALUES (?1)")
      .bind(submission.value.body)
      .run();
    return result;
  } catch (e) {
    console.error(e);
    return submission.reply({
      formErrors: ["Failed to send the message. Please try again later."],
    });
  }
}

以上