はじめに
リリースされたばかりのRemixのチュートリアルを日本語に翻訳してみました。
英語で取り掛かりにくかった方の味方になれたら幸いです。
基本的にDeepLを使った翻訳と機械翻訳すぎる部分は私の拙い英語力で翻訳しております。
公開後も読みにくい翻訳の部分を修正していきます。
Quickstart
https://remix.run/docs/en/v1/tutorials/blog#quickstart
クイックスタート
このクイックスタートでは、言葉少なに、素早くコードを書くことを目指します。もしあなたがRemixがどのようなものか15分で知りたいのであれば、これはそのためのものです。
〇 ヘイ、私はリミックスCDのデリックです。 あなたが何かをすることになったとき、必ず私を見ることになるはずです。
これはTypeScriptを使用していますが、コードを書いた後に必ず型をペタペタと貼り付けています。これは通常のワークフローとは異なりますが、TypeScriptを使用していない方もいらっしゃるので、そのような方のためにコードを乱雑にしたくはなかったのです。通常は、コードを書きながら型を作っていくので、最初から正しく作ることができます(念には念を入れよ!)。
前提条件
このチュートリアルを自分のコンピュータで行う場合、以下のものがインストールされていることが重要です。
Node.js 14 以上
npm 7 以上
コードエディター
プロジェクトの作成
〇 新しいRemixプロジェクトの初期化
少なくともNode v14以上が動作していることを確認します。
執筆時のNode.js、npmのバージョンは以下の環境を使っております。
$ node -v
v16.14.0
$ npm -v
8.3.1
チュートリアル
対話形式でプログラムを作成していきます。
まずはデフォルトのままプロジェクトを作成していきます。
$ npx create-remix@latest
Need to install the following packages:
create-remix@latest
Ok to proceed? (y)
R E M I X
○ Welcome to Remix! Let's get you set up with a new project.
? Where would you like to create your app? ./my-remix-app
? Where do you want to deploy? Choose Remix if you're unsure, it's easy to change deployment targets. Remix App Server
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? Yes
> postinstall
> remix setup node
Successfully setup Remix for node.
added 438 packages, and audited 439 packages in 1m
143 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
💿 That's it! `cd` into "my-remix-app" and check the README for development and deploy instructions!
npm notice
npm notice New minor version of npm available! 8.3.1 -> 8.5.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v8.5.0
npm notice Run npm install -g npm@8.5.0 to update!
npm notice
執筆時の環境ではnpmの更新版があることを知らせてくれています。
プロジェクト作成直後のディレクトリ構成は以下の通りです。
my-remix-app
|-- .gitignore
|-- README.md
|-- app
| |-- entry.client.tsx
| |-- entry.server.tsx
| |-- root.tsx
| `-- routes
| `-- index.tsx
|-- node_modules
|-- package-lock.json
|-- package.json
|-- public
| `-- favicon.ico
|-- remix.config.js
|-- remix.env.d.ts
`-- tsconfig.json
先ほど作成した my-remix-app のサーバーを起動してみましょう。
$ cd my-remix-app
$ npm run dev
このチュートリアルに沿って進めるのであれば、この段階でRemix App Serverを選択することが重要です。アプリをデプロイする予定がある場合、デプロイ先によってはデプロイ前にコードを更新する必要があるかもしれません。ファイルシステムへの読み書きを行うことになりますが、すべてのセットアップがそれに対応しているわけではありません(例えば、Cloudflare Workers や AWS lambda は書き込み可能なファイルシステムを持っていません)。デプロイの準備ができたら、選択したアダプタの `README` を参照して、プラットフォーム固有の手順を確認してください
http://localhost:3000 を開くと、アプリが起動しているはずです。もしよろしければ、スターター・テンプレートを少し覗いてみてください。
http://localhost:3000 でアプリケーションが正常に動作しない場合は、生成されたプロジェクトファイルの README.md を参照して、デプロイメント ターゲットに追加の設定が必要であるかどうかを確認します。
アプリを起動する前に `postinstall` スクリプトが実行されていることを確認してください - 実行されていない場合は、手動で実行します (例: `npm run postinstall`).
これは、npm の設定に ignore-scripts = true
を追加している場合や、pnpm など、Remix が依存しているポストインストールスクリプトを自動的に実行しないパッケージマネージャを使っている場合などに起こります。
最初のルート
これから、"/posts" URLでレンダリングする新しいルートを作成します。その前に、リンクを張っておきましょう。
〇 postsへのリンクを追加するため、次のファイルを編集します。 app/routes/index.tsx
まず、remixからLinkをインポートします。
import { Link } from "remix";
次に、好きな場所にリンクを貼ります。このとき、<Outlet />
や<Scripts/>
といったRemix固有のコンポーネントを削除しないように注意してください。
<Link to="/posts">Posts</Link>
私は次のようにindex.tsxを編集しました。
import { Link } from "remix"; // 追加
export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
<h1>Welcome to Remix</h1>
<Link to="/posts">Posts</Link> {/* 追加 */}
<ul>
<li>
{/* 以下略 */}
ブラウザに戻り、リンクをクリックします。このルートはまだ作成されていないので、404ページが表示されるはずです。では、ルートを作成してみましょう。
ステータスコードも404になっていることが確認できました。
〇 app/routes/posts/index.tsx に新規ファイルを作成します。
mkdir app/routes/posts
touch app/routes/posts/index.tsx
ファイルやフォルダーを作成するターミナルコマンドは、もちろん好きなように実行できますが、`mkdir`と`touch`を使うのは、どのファイルを作成すべきかを明確にするための方法です。
posts.tsx
という名前でもよかったのですが、近々別のルートができるので、お互いに並べておくといいでしょう。indexルートはフォルダのパスでレンダリングします(Webサーバーのindex.htmlのようなもの)。
おそらく、画面がnullで真っ白になるのが見えると思います。ルートはできたけど、まだ何もないんですね。コンポーネントを追加して、デフォルトとしてエクスポートしてみましょう。
〇 postsのコンポーネントを作る
app/routes/posts/index.tsx
export default function Posts() {
return (
<div>
<h1>Posts</h1>
</div>
);
}
新しく作成したpostsルートを表示するには、ブラウザをリロードする必要があるかもしれません。
データの読み込み
データの読み込みはRemixに組み込まれています。
もしあなたのウェブ開発の経歴がここ数年のものであれば、おそらくここでデータを提供するAPIルートとそれを消費するフロントエンドコンポーネントの2つを作るのに慣れていることでしょう。Remixでは、あなたのフロントエンドコンポーネントはそれ自身のAPIルートでもあり、ブラウザからサーバ上の自分自身と対話する方法をすでに知っています。つまり、あなたはデータを取得する必要がありません。
もしあなたのバックグラウンドがRailsのようなMVCウェブフレームワークより少し前にあるならば、RemixのルートはテンプレートにReactを使ったバックエンドビューと考えることができます。しかし、ユーザーインタラクションをドレスアップするために無機質なjQueryコードを書く代わりに、ブラウザでシームレスにハイドレートして華を添える方法を知っています。これは、プログレッシブ・エンハンスメントをフルに活用したものです。さらに、ルートはそれ自体がコントローラです。
それではさっそくコンポーネントにデータを提供してみましょう。
〇 postsのルートを 「ローダー」 にする。
app/routes/posts/index.tsx
import { useLoaderData } from "remix";
export const loader = async () => {
return [
{
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();
console.log(posts);
return (
<div>
<h1>Posts</h1>
</div>
);
}
ローダーはそのコンポーネントのバックエンドの「API」であり、useLoaderDataを通してすでに配線されているのだ。Remixルートではクライアントとサーバーの境界が曖昧で、ちょっと乱暴な感じです。サーバとブラウザのコンソールを両方開いてみると、どちらも私たちの投稿データを記録していることに気がつくと思います。これはRemixが従来のWebフレームワークのようにサーバでレンダリングして完全なHTMLドキュメントを送信し、クライアントでもハイドレーションしてログを記録しているからです。
〇 postsへのリンクをレンダリングする
app/routes/posts/index.tsx
import { Link, useLoaderData } from "remix";
// ...
export default function Posts() {
const posts = useLoaderData();
return (
<div>
<h1>Posts</h1>
<ul>
{posts.map(post => (
<li key={post.slug}>
<Link to={post.slug}>{post.title}</Link>
</li>
))}
</ul>
</div>
);
}
このままでは パラメーター 'post' の型は暗黙的に'any' になります。
とエラーが出てしまうため、修正する必要があります。
〇 useLoaderData
のPostをジェネリック型にする
app/routes/posts/index.tsx
import { Link, useLoaderData } from "remix";
export type Post = {
slug: string;
title: string;
};
export const loader = async () => {
const posts: Post[] = [
{
slug: "my-first-post",
title: "My First Post"
},
{
slug: "90s-mixtape",
title: "A Mixtape I Made Just For You"
}
];
return posts;
};
export default function Posts() {
const posts = useLoaderData<Post[]>();
return (
<div>
<h1>Posts</h1>
<ul>
{posts.map(post => (
<li key={post.slug}>
<Link to={post.slug}>{post.title}</Link>
</li>
))}
</ul>
</div>
);
}
おいおい、なかなかいいじゃないか。同じファイルに定義されているから、ネットワーク経由のリクエストでも、かなり強固な型安全性が得られるんだ。Remixがデータを取得する間にネットワークが爆発しない限り、このコンポーネントとそのAPIで型安全性を確保できます(コンポーネントはすでにそれ自身のAPIルートであることを思い出してください)
最終的には以下のコードになります。
app/routes/posts/index.tsx
import { Link, useLoaderData } from "remix";
export type Post = {
slug: string;
title: string;
};
export const loader = async () => {
const posts: Post[] = [
{
slug: "my-first-post",
title: "My First Post"
},
{
slug: "90s-mixtape",
title: "A Mixtape I Made Just For You"
}
];
return posts;
};
export default function Posts() {
const posts = useLoaderData<Post[]>();
console.log(posts);
return (
<div>
<h1>Posts</h1>
<ul>
{posts.map(post => (
<li key={post.slug}>
<Link to={post.slug}>{post.title}</Link>
</li>
))}
</ul>
</div>
);
}
少しリファクタリング
特定の問題に対処するためのモジュールを作成するのが堅実な方法です。私たちの場合、それは投稿を読み書きすることになります。では、それをセットアップして、モジュールにgetPosts
エクスポートを追加してみましょう。
〇 app/post.ts
を作成します。
ouch app/post.ts
ほとんどルートからコピー&ペーストすることになります。
app/post.ts
export type Post = {
slug: string;
title: string;
};
export function getPosts() {
const posts: Post[] = [
{
slug: "my-first-post",
title: "My First Post"
},
{
slug: "90s-mixtape",
title: "A Mixtape I Made Just For You"
}
];
return posts;
}
〇 postsのルートを更新して、新しいpostsモジュールを使用するようにします。
app/routes/posts/index.tsx
import { Link, useLoaderData } from "remix";
// 以下コメントアウト部分の削除
// export type Post = {
// slug: string;
// title: string;
// };
// export const loader = async () => {
// const posts: Post[] = [
// {
// slug: "my-first-post",
// title: "My First Post"
// },
// {
// slug: "90s-mixtape",
// title: "A Mixtape I Made Just For You"
// }
// ];
// return posts;
// };
// 以下追加
import { getPosts } from "~/post";
import type { Post } from "~/post";
export const loader = async () => {
return getPosts();
};
// ...
データソースからの取り出し
もしこれを実際に作るなら、Postgres、 FaunaDB、 Supabase などのデータベースに投稿を保存したいと思うだろう。今回はクイックスタートなので、ファイルシステムを使用することにします。
リンクをハードコーディングする代わりに、ファイルシステムから読み込むことにします。
〇 プロジェクトのルート(例ではmy-remix-app配下)に「posts/」フォルダを作成します。
app/routes/postsではないので注意してください。
mkdir posts
さらに、posts配下にいくつかのファイルを作成します。
touch posts/my-first-post.md
touch posts/90s-mixtape.md
front matterのtitle属性を記述します。
(後述のfront-matterモジュールが必要です。)
---
title: My First Post
---
# This is my first post
Isn't it great?
---
title: 90s Mixtape
---
# 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)
ファイルシステムから読み込むようにgetPosts
を更新します。
front-matterのモジュールを追加します。
npm add front-matter
app/post.ts
import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";
export type Post = {
slug: string;
title: string;
};
// relative to the server output not the source!
const postsPath = path.join(__dirname, "..", "posts");
export async function getPosts() {
const dir = await fs.readdir(postsPath);
return Promise.all(
dir.map(async filename => {
const file = await fs.readFile(
path.join(postsPath, filename)
);
const { attributes } = parseFrontMatter(
file.toString()
);
return {
slug: filename.replace(/\.md$/, ""),
title: attributes.title
};
})
);
}
これはNodeファイルシステムのチュートリアルではないので、そのコードについては私たちを信頼してください。先に述べたように、このマークダウンはどこかのデータベースから引き出すことができます(これは後のチュートリアルで紹介します)。
もしRemix App Serverを使わなかった場合は、おそらくパスに「...」を追加する必要があるでしょう。また、このデモは永続的なファイルシステムを持っていないところでは展開できないことに注意してください。
TypeScriptはエラーを吐くので修正していきます。
ファイルを読み込んでいるので、型システムには何が入っているかわからない。そこで、実行時のチェックが必要だ。そのためには、このような実行時のチェックを簡単にするために不変メソッドが必要だ。
postsに適切なメタデータを付与し、型の安全性を確保する。
npm add tiny-invariant
app/post.ts
import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";
import invariant from "tiny-invariant";
export type Post = {
slug: string;
title: string;
};
export type PostMarkdownAttributes = {
title: string;
};
const postsPath = path.join(__dirname, "..", "posts");
function isValidPostAttributes(
attributes: any
): attributes is PostMarkdownAttributes {
return attributes?.title;
}
export async function getPosts() {
const dir = await fs.readdir(postsPath);
return Promise.all(
dir.map(async filename => {
const file = await fs.readFile(
path.join(postsPath, filename)
);
const { attributes } = parseFrontMatter(
file.toString()
);
invariant(
isValidPostAttributes(attributes),
`${filename} has bad meta data!`
);
return {
slug: filename.replace(/\.md$/, ""),
title: attributes.title
};
})
);
}
TypeScriptを使っていなくても、何が問題なのかを知るために、型の互換性チェックは必要でしょう。
ブラウザ戻ると、postsのリストが表示されているはずです。自由にpostsを追加して、リストが増えていくのを確認しましょう。
ダイナミックルートパラメータ
では、実際に投稿を閲覧するためのルートを作ってみましょう。これらのURLを動作させたいと思います。
/posts/my-first-post
/posts/90s-mixtape
投稿の一つ一つにルートを作るのではなく、URLの中に「ダイナミックセグメント」を使うことができます。Remixがパースして渡してくれるので、動的に投稿を検索することができます。
〇 "app/routes/posts/$slug.tsx" にダイナミックルートを作成する。
app/routes/posts/$slug.tsx
export default function PostSlug() {
return (
<div>
<h1>Some Post</h1>
</div>
);
}
postsをクリックすると、新しいページが表示されるはずです。
〇 パラメータにアクセスするためのローダを追加する。
app/routes/posts/$slug.tsx
import { useLoaderData } from "remix";
export const loader = async ({ params }) => {
return params.slug;
};
export default function PostSlug() {
const slug = useLoaderData();
return (
<div>
<h1>Some Post: {slug}</h1>
</div>
);
}
ファイル名の$
の部分は、ローダーに入ってくるparams
オブジェクトの名前付きキーになります。このようにして、ブログの記事を検索することになります。
〇 ローダー関数のシグネチャはTypeScriptに助けてもらいましょう。
app/routes/posts/$slug.tsx
import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";
export const loader: LoaderFunction = async ({
params
}) => {
return params.slug;
};
では、実際にファイルシステムからpostを読み出してみましょう。
〇 postモジュールにgetPost
関数を追加します。
この関数をapp/post.ts
モジュールの任意の場所に設置します。
app/post.ts
// ...
export async function getPost(slug: string) {
const filepath = path.join(postsPath, slug + ".md");
const file = await fs.readFile(filepath);
const { attributes } = parseFrontMatter(file.toString());
invariant(
isValidPostAttributes(attributes),
`Post ${filepath} is missing attributes`
);
return { slug, title: attributes.title };
}
〇 ルートに新しいgetPost
関数を追加する
app/routes/posts/$slug.tsx
import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";
import invariant from "tiny-invariant";
import { getPost } from "~/post";
export const loader: LoaderFunction = async ({
params
}) => {
invariant(params.slug, "expected params.slug");
return getPost(params.slug);
};
export default function PostSlug() {
const post = useLoaderData();
return (
<div>
<h1>{post.title}</h1>
</div>
);
}
チェックアウト!ブラウザにJavaScriptとしてすべてを含めるのではなく、データソースからpostを引っ張ってくるようになりました。
その不変性
についての簡単なメモ。params
はURLから来るので、params.slug
が定義されていることを完全に確信することはできません--もしかしたら、ファイル名を$postId.ts
に変更するかもしれません!このようなことをinvariantで検証するのは良い習慣であり、TypeScriptも喜ぶ。
マークダウンパーサーはたくさんありますが、このチュートリアルでは簡単に使える「marked」を使用することにします。
〇 マークダウンをHTMLにパースする
npm add marked
# もしtypescriptを使っているのであれば
npm add @types/marked
app/post.ts
import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";
import invariant from "tiny-invariant";
import { marked } from "marked";
//...
export async function getPost(slug: string) {
const filepath = path.join(postsPath, slug + ".md");
const file = await fs.readFile(filepath);
const { attributes, body } = parseFrontMatter(
file.toString()
);
invariant(
isValidPostAttributes(attributes),
`Post ${filepath} is missing attributes`
);
const html = marked(body);
return { slug, html, title: attributes.title };
}
〇 ルート内のHTMLをレンダリングする
app/routes/posts/$slug.tsx
// ...
export default function PostSlug() {
const post = useLoaderData();
return (
<div dangerouslySetInnerHTML={{ __html: post.html }} />
);
}
これでブログの完成となります。
ブログ記事の作成
現在、私たちのブログ投稿(およびタイプミスの修正)はデプロイと関連付けられています。しかし、最終的には単純なタイポの変更のためにアプリ全体を再デプロイする必要がない方がはるかに良いです。ここでは、投稿はデータベースによってバックアップされるため、新しいブログ投稿を作成する方法が必要であるという考えです。そのためにアクションを使用するつもりです。
アプリの「admin」セクションを新しく作ってみましょう。
〇 管理ルートを作成する
touch app/routes/admin.tsx
app/routes/admin.tsx
import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";
export const loader = async () => {
return getPosts();
};
export default function Admin() {
const posts = useLoaderData<Post[]>();
return (
<div className="admin">
<nav>
<h1>Admin</h1>
<ul>
{posts.map(post => (
<li key={post.slug}>
<Link to={`/posts/${post.slug}`}>
{post.title}
</Link>
</li>
))}
</ul>
</nav>
<main>...</main>
</div>
);
}
このコードの多くは、投稿ルートで見覚えがあるはずです。これから素早くスタイルを設定するために、追加のHTML構造を設定しました。
〇 管理用スタイルシートを作成する
mkdir app/styles
touch app/styles/admin.css
app/styles/admin.css
.admin {
display: flex;
}
.admin > nav {
padding-right: 2rem;
}
.admin > main {
flex: 1;
border-left: solid 1px #ccc;
padding-left: 2rem;
}
em {
color: red;
}
〇 管理ルートにスタイルシートへのリンクを貼る
app/routes/admin.tsx
import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";
import adminStyles from "~/styles/admin.css";
export const links = () => {
return [{ rel: "stylesheet", href: adminStyles }];
};
// ...
各ルートは、HTMLの代わりにオブジェクト形式で、<link>
タグの配列を返すlinks
関数をエクスポートすることができます。そのため、{ rel:"stylesheet", href: adminStyles}
の代わりに <link rel="stylesheet" href="..." />
を使用します。これにより、Remixはレンダリングされたすべてのルートリンクをマージし、ドキュメントの一番上にある<Links/>
要素にレンダリングします。もし興味があれば、root.tsx
でこの他の例を見ることができます。
さて、左側に投稿、右側にプレースホルダーを配置した見栄えの良いページができたはずです。今のところ、ナビゲーショナルリンクをまだ設定していないので、手動で http://localhost:3000/admin に移動する必要があります。
インデックスルート
そのプレースホルダーを、管理者用のインデックスルートで埋めてみましょう。ルートファイルのネストがUIコンポーネントのネストになる「ネストされたルート」を紹介しますので、ご一読ください。
〇 admin.tsx
の子ルート用のフォルダを作成し、その中にインデックスを作成します。
app/routes/admin/index.tsx
import { Link } from "remix";
export default function AdminIndex() {
return (
<p>
<Link to="new">Create a New Post</Link>
</p>
);
}
リフレッシュするとまだ表示されません。app/routes/admin/ 内のすべてのルートは、URLが一致すると app/routes/admin.tsx の内部でレンダリングできるようになりました。子ルートがadmin.tsxレイアウトのどの部分をレンダリングするかは、あなたがコントロールできます。
〇 管理画面にアウトレットを追加する
app/routes/admin.tsx
import { Outlet, Link, useLoaderData } from "remix";
//...
export default function Admin() {
const posts = useLoaderData<Post[]>();
return (
<div className="admin">
<nav>
<h1>Admin</h1>
<ul>
{posts.map(post => (
<li key={post.slug}>
<Link to={`/posts/${post.slug}`}>
{post.title}
</Link>
</li>
))}
</ul>
</nav>
<main>
<Outlet />
</main>
</div>
);
}
インデックスルートは最初分かりにくいかもしれませんが、少し待ってください。ただ、URLが親ルートのパスと一致すると、アウトレット内でインデックスがレンダリングされることを知っておいてください。
多分これが役に立つでしょう。「/admin/new」ルートを追加して、リンクをクリックしたときに何が起こるか見てみましょう。
〇 app/routes/admin/new.tsx
ルートを作成します。
touch app/routes/admin/new.tsx
app/routes/admin/new.tsx
export default function NewPost() {
return <h2>New Post</h2>;
}
インデックスルートからのリンクをクリックすると、<Outlet/>
がインデックスルートを「新しい」ルートに自動的に交換するのを確認してください。
アクション
これからが本番です。新しい「new」ルートに新しい投稿を作成するためのフォームを作りましょう。
〇 新しいルートにフォームを追加する
app/routes/admin/new.tsx
import { Form } from "remix";
export default function NewPost() {
return (
<Form method="post">
<p>
<label>
Post Title: <input type="text" name="title" />
</label>
</p>
<p>
<label>
Post Slug: <input type="text" name="slug" />
</label>
</p>
<p>
<label htmlFor="markdown">Markdown:</label>
<br />
<textarea id="markdown" rows={20} name="markdown" />
</p>
<p>
<button type="submit">Create Post</button>
</p>
</Form>
);
}
もしあなたが私たちのようにHTMLを愛しているなら、かなり興奮しているはずです。<form onSubmit>
や<button onClick>
を多用していた人は、HTMLに心を奪われることでしょう。
このような機能に本当に必要なのは、ユーザーからデータを取得するフォームと、それを処理するバックエンドのアクションだけです。そしてRemixでは、それもすべて行う必要があるのです。
まず、投稿を保存する方法を知っている必須のコードをpost.tsモジュールに作成しましょう。
〇 app/post.ts
内の任意の場所に createPost
を追加する。
app/post.ts
// ...
export async function createPost(post) {
const md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
await fs.writeFile(
path.join(postsPath, post.slug + ".md"),
md
);
return getPost(post.slug);
}
〇 新しい post ルートのアクションから createPost を呼び出します。
app/routes/admin/new.tsx
import { redirect, Form } from "remix";
import { createPost } from "~/post";
export const action = async ({ request }) => {
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("/admin");
};
export default function NewPost() {
// ...
}
それだけです。あとはRemixが(そしてブラウザが)処理してくれます。投稿ボタンをクリックすると、私たちの投稿を一覧表示するサイドバーが自動的に更新されます。
HTMLでは、入力のname
属性はネットワーク経由で送信され、リクエストのformData
で同じ名前で利用できます。
TypeScriptはまたエラー吐くため、いくつか型を追加します。
〇 先ほど変更した両方のファイルに型を追加します。
app/post.ts
// ...
type NewPost = {
title: string;
slug: string;
markdown: string;
};
export async function createPost(post: NewPost) {
const md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
await fs.writeFile(
path.join(postsPath, post.slug + ".md"),
md
);
return getPost(post.slug);
}
//...
app/routes/admin/new.tsx
import { Form, redirect } from "remix";
import type { ActionFunction } from "remix";
import { createPost } from "~/post";
export const action: ActionFunction = async ({
request
}) => {
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("/admin");
};
TypeScriptを使用しているかどうかにかかわらず、ユーザーがこれらのフィールドのいくつかに値を提供しない場合、問題が発生する(そしてTypeScriptはcreatePost
の呼び出しについてまだエラーが出ています)。
投稿を作成する前に、バリデーションを追加してみましょう。
〇 フォームデータに必要なものが含まれているかどうかを検証し、含まれていない場合はエラーを返します。
app/routes/admin/new.tsx
//...
export const action: ActionFunction = async ({
request
}) => {
const formData = await request.formData();
const title = formData.get("title");
const slug = formData.get("slug");
const markdown = formData.get("markdown");
const errors = {};
if (!title) errors.title = true;
if (!slug) errors.slug = true;
if (!markdown) errors.markdown = true;
if (Object.keys(errors).length) {
return errors;
}
await createPost({ title, slug, markdown });
return redirect("/admin");
};
今回はリダイレクトを返さず、実際にエラーを返していることに注意してください。これらのエラーは useActionData
を通してコンポーネントから利用することができます。これは useLoaderData
と同じですが、データはフォームの POST の後にアクションから取得されます。
〇 UIにバリデーションメッセージを追加する
app/routes/admin/new.tsx
import { useActionData, Form, redirect } from "remix";
import type { ActionFunction } from "remix";
// ...
export default function NewPost() {
const errors = useActionData();
return (
<Form method="post">
<p>
<label>
Post Title:{" "}
{errors?.title ? (
<em>Title is required</em>
) : null}
<input type="text" name="title" />
</label>
</p>
<p>
<label>
Post Slug:{" "}
{errors?.slug ? <em>Slug is required</em> : null}
<input type="text" name="slug" />
</label>
</p>
<p>
<label htmlFor="markdown">Markdown:</label>{" "}
{errors?.markdown ? (
<em>Markdown is required</em>
) : null}
<br />
<textarea id="markdown" rows={20} name="markdown" />
</p>
<p>
<button type="submit">Create Post</button>
</p>
</Form>
);
}
TypeScriptはまだエラーを吐くので、エラーオブジェクトのためにinvariantと型を指定していきます。
開発ツールでJavaScriptを無効にして試してみてください。RemixはHTTPとHTMLの基礎の上に構築されているため、ブラウザ上でJavaScriptを使わなくても動作します。しかし、それは重要なことではありません。それでは、フォームに「保留のUI」を追加してみましょう。
〇 遅延でアクションを遅くしてみる
app/routes/admin/new.tsx
// ...
export const action: ActionFunction = async ({
request
}) => {
await new Promise(res => setTimeout(res, 1000));
const formData = await request.formData();
const title = formData.get("title");
const slug = formData.get("slug");
const markdown = formData.get("markdown");
// ...
};
//...
〇 useTransitionを使い、保留のUIを追加します
app/routes/admin/new.tsx
import {
useTransition,
useActionData,
Form,
redirect
} from "remix";
// ...
export default function NewPost() {
const errors = useActionData();
const transition = useTransition();
return (
<Form method="post">
{/* ... */}
<p>
<button type="submit">
{transition.submission
? "Creating..."
: "Create Post"}
</button>
</p>
</Form>
);
}
これで、ユーザーは、ブラウザにJavaScriptをまったく入れずにこれを実行した場合よりも、優れたエクスペリエンスを得ることができます。他にも、タイトルを自動的にスラッグフィールドに入力したり、ユーザーがそれを上書きできるようにしたりすることもできます(後で追加するかもしれません)。
今日はここまでです。あなたの宿題は、投稿用の /admin/edit
ページを作ることです。リンクはすでにサイドバーにありますが、404を返します。投稿を読み込んで、それをフィールドに入れる新しいルートを作ってください。必要なコードはすべて、app/routes/posts/$slug.tsx
と app/routes/admin/new.tsx
にすでにあります。ただ、それをまとめるだけです。
ぜひRemixをご愛用ください。
参考
https://qiita.com/watataku8911/items/6c0bdc217d0d46513bd3
Remixのチュートリアルをやってみた
https://dev.classmethod.jp/articles/qucikstart-remix-framework/
ReactベースのあたらしいフレームワークRemixをためしてみた