Remix Blog Tutorialをやる
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
へのリンクを追加する。
// 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を作成する。
export default function Posts() {
return (
<main>
<h1>Posts</h1>
</main>
);
}
ブラウザ更新(npm run dev
再実行?)をすることでPostsページが表示される。
データの読み込み
データの読み込みはRemixに組み込まれている。
Remixではフロントエンドコンポーネントはそれ自身のAPIルートでもあるため、フェッチする必要がない。
Reactをテンプレート化したバックエンドビューと考えることもでき、ユーザーインタラクションを粉うためのjQueryコードを書く代わりに、ブラウザでシームレスにハイドレートすることもできる。
実際にコンポーネントでデータの読み込みを行ってみる。
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
のリンクを追加する。
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
エクスポートを追加する。
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
に書き換える。
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の更新を行う。
// 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
データベースにデータを入れる。
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ディレクトリのエイリアスでファイル移動の際の../../の数を気にする必要がない
import { prisma } from "~/db.server";
// PrismaのTypeScript機能により、手作業による型付けが少なく、かつ安全な型付けが可能となる。
export async function getPosts() {
return prisma.post.findMany();
}
Prismaクライアントが更新されたので、devサーバーを停止し、npm run devで再起動させる。
Dynamic Route Params
/** @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
を使用する。
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関数を追加する。
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関数を使用する。
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へのリンクを追加する。
// ...
<Link to="admin" className="text-red-600 underline">
Admin
</Link>
// ...
Remixでは相対リンクになるため、to="admin"
だけでも/posts/admin
にリンクしている。
postsディレクトリ内にadmin routeを作成する
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を作成する
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レイアウトのどの部分をレンダリングするかは、自分でコントロールすることができる。
管理画面にアウトレットを追加する
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を作成する
export default function NewPost() {
return <h2>New Post</h2>;
}
Actions
ここから本格的な作業になる。newルートに新しい投稿を作成するためのフォームを作る。
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 を追加する
// ...
export async function createPost(post) {
return prisma.post.create({ data: post });
}
newルートのアクションからcreatePostを呼び出し
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仕様そのままであり、RequestとformDataについての詳細は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")で利用可能
投稿前にバリデーションを追加する
フォームに必要なものが含まれているかを確認し、含まれていなければエラーを返す。
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にバリデーションメッセージを追加する
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の型修正
import type { Post } from "@prisma/client";
// ...
export async function createPost(
post: Post:<Post, "slug"|"title"|"markdown">
) {
return prisma.post.create({ data: post });
}
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を追加してみる。
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