🐈

Remix Blog Tutorialをやる

2023/07/03に公開

Quickstart

  • Remix Blog TutorialはRemixがどんなものなのか、15分程度で確認できるもの。
  • 講座ではTypeScriptを使用(JavaScriptでも問題ない)。

環境

  • Node.js version (^14.17.0, or >=16.0.0)
  • npm 7以上
  • コードエディタ (VSCodeが良い?)

Tutorial

Creating the project

以下コマンドを実行してRemixプロジェクトを作成する。

npx create-remix@latest --template remix-run/indie-stack blog-tutorial

利用可能なスタックについては、スタックのドキュメントを参照。

Your First Route

/postsのURLでレンダリングするルートを作成

まずは、routes/_index.tsxの中に/postsへのリンクを追加する。

app/routes/_index.tsx
// 68-72行目
<div className="mx-auto mt-16 max-w-7xl text-center">
  <Link
    to="/posts"
    className="text-xl text-blue-600 underline"
  >
    Blog Posts
  </Link>
</div>

次にapp/routes/にposts._index.tsxを作成する。

app/routes/posts._index.tsx
export default function Posts() {
  return (
    <main>
      <h1>Posts</h1>
    </main>
  );
}

ブラウザ更新(npm run dev再実行?)をすることでPostsページが表示される。

データの読み込み

データの読み込みはRemixに組み込まれている。
Remixではフロントエンドコンポーネントはそれ自身のAPIルートでもあるため、フェッチする必要がない。
Reactをテンプレート化したバックエンドビューと考えることもでき、ユーザーインタラクションを粉うためのjQueryコードを書く代わりに、ブラウザでシームレスにハイドレートすることもできる。

実際にコンポーネントでデータの読み込みを行ってみる。

app/routes/posts._index.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const loader = async () => {
  return json({
    posts: [
      {
        slug: "my-first-post",
        title: "My First Post",
      },
      {
        slug: "90s-mixtape",
        title: "A Mixtape I Made Just For You",
      },
    ],
  });
};

export default function Posts() {
  const { posts } = useLoaderData<typeof loader>();
  console.log(posts);
  return (
    <main>
      <h1>Posts</h1>
    </main>
  );
}

この例でのloaderはバックエンドAPIであり、useLoaderDataによって接続されている。Remixのルートではクライアントとサーバーとの境界が曖昧であるが、dev toolでログを確認すると、クライアント、サーバーどちらも投稿データを記録している。
これはRemixが従来のWebフレームワークのようにサーバーでレンダリングしてHTMLを送信してるためだが、クライアントでもハイドレーションしてログを記録しているためである。

loaderから返されるものはコンポーネントで使用しなくても、クライアントに公開させる。そのため、パブリックAPIのエンドポイントと同じように慎重に扱う必要がある。

このページにpostsのリンクを追加する。

app/routes/posts._index.tsx
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";

// ...
export default function Posts() {
  const { posts } = useLoaderData<typeof loader>();
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>
            <Link
              to={post.slug}
              className="text-blue-600 underline"
            >
              {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

postsが同じファイルに定義されているため、ネットワーク経由のリクエストでも型安全性が確保されている。Remixがデータを取得している間にネットワークが切断しない限りは型安全性が確保できる。(コンポーネントはすでに独自のAPIルートになっている)

ちょっとしたリファクタリング
postsの読み込みを切り出してモジュールにgetPostsエクスポートを追加する。

app/models/post.server.ts
type Post = {
  slug: string;
  title: string;
};

export async function getPosts(): Promise<Array<Post>> {
  return [
    {
      slug: "my-first-post",
      title: "My First Post",
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You",
    },
  ];
}

作成したgetPostsに書き換える。

app/routes/posts._index.tsx
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";

import { getPosts } from "~/models/post.server";

export const loader = async () => {
  return json({ posts: await getPosts() });
};

データソースから取得

Indie Stackでは、SQLiteデータベースがすでにセットアップされ設定されているので、SQLiteを扱うためにデータベーススキーマを更新する。データベースとのやり取りにはPrismaを使用しているので、スキーマを更新すれば、Prismaがデータベースをスキーマに合わせて更新してくれる(移行に必要なSQLコマンドの生成と実行も行う)。

まずは、prismaの更新を行う。

prisma/schema.prisma
// Stick this at the bottom of that file:

model Post {
  slug     String @id
  title    String
  markdown String

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

スキーマの変更に合わせて、ローカルのデータベースとTypeScriptの定義を更新するように以下コマンドを実行。

npx prisma db push

データベースにデータを入れる。

prisma/seed.ts
const posts = [
  {
    slug: "my-first-post",
    title: "My First Post",
    markdown: `
# This is my first post

Isn't it great?
    `.trim(),
  },
  {
    slug: "90s-mixtape",
    title: "A Mixtape I Made Just For You",
    markdown: `
# 90s Mixtape

- I wish (Skee-Lo)
- This Is How We Do It (Montell Jordan)
- Everlong (Foo Fighters)
- Ms. Jackson (Outkast)
- Interstate Love Song (Stone Temple Pilots)
- Killing Me Softly With His Song (Fugees, Ms. Lauryn Hill)
- Just a Friend (Biz Markie)
- The Man Who Sold The World (Nirvana)
- Semi-Charmed Life (Third Eye Blind)
- ...Baby One More Time (Britney Spears)
- Better Man (Pearl Jam)
- It's All Coming Back to Me Now (Céline Dion)
- This Kiss (Faith Hill)
- Fly Away (Lenny Kravits)
- Scar Tissue (Red Hot Chili Peppers)
- Santa Monica (Everclear)
- C'mon N' Ride it (Quad City DJ's)
    `.trim(),
  },
];

for (const post of posts) {
  await prisma.post.upsert({
    where: { slug: post.slug },
    update: post,
    create: post,
  });
}

以下コマンドを実行して追加したデータをデータベースに取り込む。

npx prisma db seed

スキーマの変更に伴うマイグレーションファイルを生成する。

npx prisma migrate dev

SQLiteデータベースから読み込むために、app/models/post.server.tsファイルを以下内容に更新する。

app/models/post.server.ts
// ~はappディレクトリのエイリアスでファイル移動の際の../../の数を気にする必要がない
import { prisma } from "~/db.server";

// PrismaのTypeScript機能により、手作業による型付けが少なく、かつ安全な型付けが可能となる。
export async function getPosts() {
  return prisma.post.findMany();
}

Prismaクライアントが更新されたので、devサーバーを停止し、npm run devで再起動させる。

Dynamic Route Params

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  cacheDirectory: "./node_modules/.cache/remix",
  future: {
    v2_errorBoundary: false,
    v2_meta: false,
    v2_normalizeFormMethod: false,
    v2_routeConvention: false,
  },
  ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"],
};

以下のURLで動作するような投稿閲覧ページを作っていく。

/posts/my-first-post
/posts/90s-mixtape

投稿ごとにルートを作成するのではなく、urlにdynamic segmentを使用する。

app/routes/posts.$slug.tsx
export default function PostSlug() {
  return (
    <main className="mx-auto max-w-4xl">
      <h1 className="my-6 border-b-2 text-center text-3xl">
        Some Post
      </h1>
    </main>
  );
}

postモジュールにgetPost関数を追加する。

app/models/post.server.ts
import { prisma } from "~/db.server";

export async function getPosts() {
  return prisma.post.findMany();
}

export async function getPost(slug: string) {
  return prisma.post.findUnique({ where: { slug } });
}

作成したgetPost関数を使用する。

app/routes/posts.$slug.tsx
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";

import { getPost } from "~/models/post.server";

export const loader = async ({ params }: LoaderArgs) => {
  invariant(params.slug, `params.slug is required`);

  const post = await getPost(params.slug);
  invariant(post, `Post not found: ${params.slug}`);

  return json({ post });
};

export default function PostSlug() {
  const { post } = useLoaderData<typeof loader>();
  return (
    <main className="mx-auto max-w-4xl">
      <h1 className="my-6 border-b-2 text-center text-3xl">
        {post.title}
      </h1>
    </main>
  );
}

paramsはURLから取得するため、params.slugが定義されているかどうかを完全に確認することはできない。そのような場合は、invariantで検証すると良い。

Nested Routing

このままだとブログの記事はDBから参照するだけなので、DBに新しく記事を追加する処理が必要。
そのためにはアクションを使うことができる。
まず、post index routeにadmin sectionへのリンクを追加する。

app/routes/posts._index.tsx
// ...
<Link to="admin" className="text-red-600 underline">
  Admin
</Link>
// ...

Remixでは相対リンクになるため、to="admin"だけでも/posts/adminにリンクしている。

postsディレクトリ内にadmin routeを作成する

postsディレクトリ内にadmin.tsxを作成する

app/routes/posts.admin.tsx
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";

import { getPosts } from "~/models/post.server";

export const loader = async () => {
  return json({ posts: await getPosts() });
};

export default function PostAdmin() {
  const { posts } = useLoaderData<typeof loader>();
  return (
    <div className="mx-auto max-w-4xl">
      <h1 className="my-6 mb-2 border-b-2 text-center text-3xl">
        Blog Admin
      </h1>
      <div className="grid grid-cols-4 gap-6">
        <nav className="col-span-4 md:col-span-1">
          <ul>
            {posts.map((post) => (
              <li key={post.slug}>
                <Link
                  to={post.slug}
                  className="text-blue-600 underline"
                >
                  {post.title}
                </Link>
              </li>
            ))}
          </ul>
        </nav>
        <main className="col-span-4 md:col-span-3">
          ...
        </main>
      </div>
    </div>
  );
}

posts.admin.tsxの子ルート用のindexを作成する

app/routes/posts.admin._index.tsx
import { Link } from "@remix-run/react";

export default function AdminIndex() {
  return (
    <p>
      <Link to="new" className="text-blue-600 underline">
        Create a New Post
      </Link>
    </p>
  );
}

app/routes/posts.adminで始まるすべてのルートは、URLが一致するとapp/routes/posts.admin.tsxの内部でレンダリングできるようになる。子ルートがposts.admin.tsxレイアウトのどの部分をレンダリングするかは、自分でコントロールすることができる。

管理画面にアウトレットを追加する

app/routes/posts.admin.tsx
import { json } from "@remix-run/node";
import {
  Link,
  Outlet,
  useLoaderData,
} from "@remix-run/react";

import { getPosts } from "~/models/post.server";

export const loader = async () => {
  return json({ posts: await getPosts() });
};

export default function PostAdmin() {
  const { posts } = useLoaderData<typeof loader>();
  return (
    <div className="mx-auto max-w-4xl">
      <h1 className="my-6 mb-2 border-b-2 text-center text-3xl">
        Blog Admin
      </h1>
      <div className="grid grid-cols-4 gap-6">
        <nav className="col-span-4 md:col-span-1">
          <ul>
            {posts.map((post) => (
              <li key={post.slug}>
                <Link
                  to={post.slug}
                  className="text-blue-600 underline"
                >
                  {post.title}
                </Link>
              </li>
            ))}
          </ul>
        </nav>
        <main className="col-span-4 md:col-span-3">
          <Outlet />
        </main>
      </div>
    </div>
  );
}

最初はindexルートは混乱するかもしれないが、URLが親ルートのパスと一致するとindexがOutlet内でレンダリングされることを覚えておくこと。

app/routes/posts.admin.new.tsxを作成する

app/routes/posts.admin.new.tsx
export default function NewPost() {
  return <h2>New Post</h2>;
}

Actions

ここから本格的な作業になる。newルートに新しい投稿を作成するためのフォームを作る。

app/route/posts.admin.new.tsx
import { Form } from "@remix-run/react";

const inputClassName = `w-full rounded border border-gray-500 px-2 py-1 text-lg`;

export default function NewPost() {
  return (
    <Form method="post">
      <p>
        <label>
          Post Title:{" "}
          <input
            type="text"
            name="title"
            className={inputClassName}
          />
        </label>
      </p>
      <p>
        <label>
          Post Slug:{" "}
          <input
            type="text"
            name="slug"
            className={inputClassName}
          />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown:</label>
        <br />
        <textarea
          id="markdown"
          rows={20}
          name="markdown"
          className={`${inputClassName} font-mono`}
        />
      </p>
      <p className="text-right">
        <button
          type="submit"
          className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
        >
          Create Post
        </button>
      </p>
    </Form>
  );
}

もしあなたが私たちのようにHTMLを愛しているなら、あなたはかなり興奮しているはずだ。
<form onSubmit>や<button onClick>を多用している人は、これからHTMLに心を奪われることになるでだろう。

このようなフォーム機能に本当に必要なのはユーザーからデータを取得するフォームと、それを処理するバックエンドアクションだけであり、RemixはそれだけでOKである。

post.tsモジュールに、最初に投稿を保存する方法を知っている必須のコードを作成しましょう。

app/models/post.server.ts 内の任意の場所に createPost を追加する

app/models/post.server.ts
// ...
export async function createPost(post) {
  return prisma.post.create({ data: post });
}

newルートのアクションからcreatePostを呼び出し

app/route/posts.admin.new.tsx
import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";

import { createPost } from "~/models/post.server";

export const action = async ({ request }: ActionArgs) => {
  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");

  await createPost({ title, slug, markdown });

  return redirect("/posts/admin");
};

// ...

Remix(ブラウザ)が残りの処理を行うためこれだけでよい。送信ボタンをクリックすると投稿一覧が表示されているサイドバーが自動的に更新される。
HTMLではinputのname属性はネットワーク経由で送信され、RequestのformDataで同じ名前で利用できる。
これはどちらもWeb仕様そのままであり、RequestformDataについての詳細はMDNで確認できる。

<input
  type="text"
  name="title"
  className={inputClassName}
/>

<input
  type="text"
  name="slug"
  className={inputClassName}
/>

const title = formData.get("title"); // name="title"をformData.get("title")で利用可能
const slug = formData.get("slug"); // name="slug"をformData.get("slug")で利用可能

投稿前にバリデーションを追加する

フォームに必要なものが含まれているかを確認し、含まれていなければエラーを返す。

app/routes/posts.admin.new.tsx
import type { ActionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";

import { createPost } from "~/models/post.server";

export const action = async ({ request }: ActionArgs) => {
  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");

  // バリデーション処理
  const errors = {
    title: title ? null : "Title is required",
    slug: slug ? null : "Slug is required",
    markdown: markdown ? null : "Markdown is required",
  };
  const hasErrors = Object.values(errors).some(
    (errorMessage) => errorMessage
  );
  if (hasErrors) {
    return json(errors);
  }

  await createPost({ title, slug, markdown });

  return redirect("/posts/admin");
};
// ...

この例では実際にエラーを返していることに注意。これらのエラーはuseActionDataによってコンポーネントが利用できるようになる。useLoaderDataと同じで、データはフォームのPOST後のアクションから送られてくる。

UIにバリデーションメッセージを追加する

app/routes/posts.admin.new.tsx
import type { ActionArgs } from "@remix-run/node";
import { redirect, json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";

// ...

const inputClassName = `w-full rounded border border-gray-500 px-2 py-1 text-lg`;

export default function NewPost() {
  const errors = useActionData<typeof action>();

  return (
    <Form method="post">
      <p>
        <label>
          Post Title:{" "}
          {errors?.title ? (
            <em className="text-red-600">{errors.title}</em>
          ) : null}
          <input type="text" name="title" className={inputClassName} />
        </label>
      </p>
      <p>
        <label>
          Post Slug:{" "}
          {errors?.slug ? (
            <em className="text-red-600">{errors.slug}</em>
          ) : null}
          <input type="text" name="slug" className={inputClassName} />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">
          Markdown:{" "}
          {errors?.markdown ? (
            <em className="text-red-600">
              {errors.markdown}
            </em>
          ) : null}
        </label>
        <br />
        <textarea
          id="markdown"
          rows={20}
          name="markdown"
          className={`${inputClassName} font-mono`}
        />
      </p>
      <p className="text-right">
        <button
          type="submit"
          className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
        >
          Create Post
        </button>
      </p>
    </Form>
  );
}

TypeScriptの型修正

app/models/post.server.ts
import type { Post } from "@prisma/client"// ...
export async function createPost(
  post: Post:<Post, "slug"|"title"|"markdown">
) {
  return prisma.post.create({ data: post });
}
app/routes/posts.admin.new.tsx
import invariant from "tiny-invariant";
// ..

export const action = async ({ request }: ActionArgs) => {
  // ...
  invariant(
    typeof title === "string",
    "title must be a string"
  );
  invariant(
    typeof slug === "string",
    "slug must be a string"
  );
  invariant(
    typeof markdown === "string",
    "markdown must be a string"
  );

  await createPost({ title, slug, markdown });

  return redirect("/posts/admin");
};

Progressive Enhancement

RemixではHPPTとHTMLの基礎の上に構築されているため、dev toolでJavaScriptを無効にしても全体は動作する。しかし、それは重要ではなくRemixのUIはネットワークの問題にも強いということである。
しかし、JavaScriptがあるとできることがたくさんある。フォームに保留中のUIを追加してみる。

app/routes/posts.admin.new.tsx
import type { ActionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
  Form,
  useActionData,
  useNavigation,
} from "@remix-run/react";

// ..

export default function NewPost() {
  const errors = useActionData<typeof action>();

  const navigation = useNavigation();
  const isCreating = Boolean(
    navigation.state === "submitting"
  );

  return (
    <Form method="post">
      {/* ... */}
      <p className="text-right">
        <button
          type="submit"
          className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
          disabled={isCreating}
        >
          {isCreating ? "Creating..." : "Create Post"}
        </button>
      </p>
    </Form>
  );
}

Discussion