はじめに
こんにちは!今回は、モダンなWeb開発フレームワークであるRemixを使って、ブログアプリケーションを作成する方法を紹介します。
Remixは、React用のフレームワークで、サーバーサイドレンダリングとクライアントサイドルーティングを組み合わせた効率的なアプリケーション開発を可能にします。
私自身プログラミング言語やRemixを学習し始めて1か月程度なので至らぬ文ではありますが、誰かの役に立てれば幸いです
この記事では、Remixの基本的なコンセプトや機能を説明しながら、実際にブログアプリケーションを構築していきます。初めてRemixを触る方にもわかりやすいように、コードの説明を詳細に行っていますので、ぜひ参考にしてみてください。
公式のBlog Tutorialは以下です。
それでは、始めていきましょう!
インストール
まずはRemixをインストールしましょう。
テキストエディタ(今回私はVisual Studio Codeを使用しました。)を開き、自分の指定したフォルダを開き、Terminalで以下のコマンドを打ちましょう!
npx create-remix@latest --template remix-run/indie-stack blog-tutorial
このとき3つの質問を投げられますが、すべてYesで大丈夫です。
Remixのデイレクトリ構造
ここでインストールしたRemixのデイレクトリ構造をざっくりと説明します。
以下がインストールした際のデイレクトリ構造です。
blog-tutorial/
├── app/
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── root.tsx
│ ├── routes/
│ │ ├── index.tsx
│ │ └── ...
│ ├── models/
│ │ └── ...
│ ├── utils/
│ │ └── ...
│ ├── styles/
│ │ └── ...
│ └── components/
│ └── ...
├── public/
│ └── ...
├── prisma/
│ └── schema.prisma
├── .env
├── .gitignore
├── package.json
├── remix.config.js
└── tsconfig.json
app/
app/
: Remixアプリケーションのメインディレクトリです。
-
entry.client.tsx
: クライアントサイドのエントリーポイントファイルです。 -
entry.server.tsx
: サーバーサイドのエントリーポイントファイルです。 -
root.tsx
: アプリケーションのルートコンポーネントです。 -
routes/
: ルートコンポーネントが配置されるディレクトリです。-
index.tsx
: トップページのルートコンポーネントです。 - 他のルートコンポーネントもここに配置されます。
-
-
app/models/
: データモデルやデータベースアクセス関数が配置されるディレクトリです。 -
app/utils/
: ユーティリティ関数が配置されるディレクトリです。 -
app/styles/
: グローバルスタイルやスタイル関連のファイルが配置されるディレクトリです。 -
app/components/
: 再利用可能なコンポーネントが配置されるディレクトリです。
public/
-
public/
: 静的ファイル(画像、フォントなど)を配置するディレクトリです。
prisma/
-
prisma/
: Prisma関連のファイルが配置されるディレクトリです。-
schema.prisma
: データベーススキーマを定義するファイルです。
-
.env
-
.env
: 環境変数を定義するファイルです。
.gitignore
-
.gitignore
: Gitの追跡対象から除外するファイルやディレクトリを指定するファイルです。
package.json
-
package.json
: プロジェクトの依存関係やスクリプトを定義するファイルです。
remix.config.js
-
remix.config.js
: Remixの設定ファイルです。
tsconfig.json
-
tsconfig.json
: TypeScriptの設定ファイルです。
以上がざっくりとしたデイレクトリ構造です!
ここら辺は触っていくうちに自然と掴んでくるので流し見で大丈夫です
ルートの作成
Remixでは、先ほども説明したように app/routes
ディレクトリ内のファイルがルートとなります。
まずは、トップページ(app/routes/_index.tsx
)に新しいページへのリンクを追加します。
<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>
コードの説明
このコードでは、React組み込みのLink
コンポーネントを使って、ブログの投稿一覧ページへのリンクを作成しています。
Link
コンポーネントは、Remixアプリケーション内の他のページへ遷移するためのリンクを作成するために使用されます。to
属性で遷移先のパスを指定します。
div
要素とLink
要素には、Tailwind CSSのクラス名が適用されています。
Tailwind CSSのClass名は以下のリンクを参照してみてください!
<div className="mx-auto mt-16 max-w-7xl text-center">
このコードは<div>
要素のクラスを表しています。
-
mx-auto
: 要素を水平方向に中央寄せにします。 -
mt-16
: 要素の上部に16ユニット分(4rem)のマージンを追加します。 -
max-w-7xl
: 要素の最大幅を7xl(80rem)に設定します。 -
text-center
: テキストを中央寄せにします。
これにより、<div>
要素が中央に配置され、上部に適切な余白が確保され、最大幅が制限され、テキストが中央揃えになります。
<Link className="text-xl text-blue-600 underline">
このコードは<Link>
要素のクラスを表しています。
-
text-xl
: テキストのサイズをxl(1.25rem)に設定します。 -
text-blue-600
: テキストの色を青色の600番(#2563eb)に設定します。 -
underline
: テキストに下線を引きます。
これにより、 要素のテキスト "Blog Posts" が、大きめのサイズで青色になり、下線が引かれたリンクとして表示されます。
開発用サーバーの起動
次に、開発用サーバーを起動して、アプリケーションを確認しましょう。
プロジェクトのルートディレクトリに移動し、以下のコマンドを実行します。
cd blog-tutorial
npm run dev
npm run dev
を実行すると以下のように http://localhost:3000
が立ち上がるのでクリックしてブラウザで確認してみてください!
ブラウザでは、"Blog Posts"というテキストのリンクがページの中央に表示されています。リンクの文字サイズはやや大きめ(xl)で、青色(blue-600)で下線が引かれています。
クリックすると、/posts
というURLパスを持つページに遷移します。
投稿一覧ページの作成
次に、ブログ記事の一覧を表示するページを作成します。app/routes/posts/index.tsx
ファイルを作成し、以下のようにコンポーネントを定義します。
touch app/routes/posts._index.tsx
ここで1つ余談ですが、ファイル名の命名規則を軽く説明します。
Remix では、app/routes
ディレクトリ内のファイル構造がルーティングの構造を決定します。
posts._index.tsx
という名前のファイルは、/posts
ルートに対応するインデックスルートを表します。
-
posts
: ファイル名の最初の部分は、ルートのパスを表します。この場合、/posts
というパスを表しています。 -
._index
: ファイル名の後半部分は、そのルートのインデックスルートを表します。._index
は、/posts
パスに対応するインデックスルートを示しています。 -
.tsx
: ファイルの拡張子は、TypeScript と JSX を使用していることを示しています。
この命名規則は、コードの構造を明確にし、ルーティングとコンポーネントの関係を理解しやすくします。posts
ディレクトリ内に _index.tsx
ファイルを配置することで、/posts
ルートに対応するインデックスルートであることが一目で分かります。
詳しくは下記の記事を参照してみてください。
では作成したファイルに下記のコードを書いてみましょう!
export default function Posts() {
return (
<main>
<h1>Posts</h1>
</main>
);
}
コードの説明
-
export default
は、JavaScriptのモジュールシステムにおいて、そのファイルから他のファイルへデフォルトでエクスポートする要素を指定するための構文です。
一つのファイルにつき、export default
は一つしか使えません。 -
function
は、JavaScriptの関数を定義するためのキーワードです。関数は、特定のタスクを実行したり、値を計算して返したりするための再利用可能なコードブロックです。
※ここで定義している関数は"Posts"です。
export default function
を組み合わせると、次のような意味になります。
- このファイルから、指定された関数"Posts"をデフォルトでエクスポートします。
- 他のファイルから、このファイルをインポートする際、
export default
で指定された関数"Posts"が自動的にインポートされます。 - インポート側のファイルでは、任意の名前を使ってこの関数"Posts"を参照できます。
このように export default
を使うことで、他のファイルからコンポーネントを簡単に再利用できるようになります。
これは、Reactアプリケーションの構築において非常に重要な概念です。
このファイルを作成することで、先ほど作成したリンク先を確認すると、"Posts"というタイトルが表示されるはずです。
データの読み込み
Remixでは、loader
関数を使ってデータを読み込むことができます。app/routes/posts._index.tsx
に以下のコードを追加します。
import { json } from "@remix-run/node";
import { Link, 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();
console.log(posts);
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>
);
}
コードの説明
import { json } from "@remix-run/node";
Remixのjson
関数をインポートしています。これは、データをJSON形式でレスポンスするために使用されます。
import { Link, useLoaderData } from "@remix-run/react";
RemixのLink
コンポーネントとuseLoaderData
フックをインポートしています。
-
Link
は、Remixアプリケーション内の他のページへのリンクを作成するために使用されます。 -
useLoaderData
は、loader
関数から返されたデータを取得するために使用されます。
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",
},
],
});
};
loader
関数は、ページが読み込まれる前にデータを取得するために使用されます。
関数内では、json
関数を使用して、posts
というキーに投稿データの配列を持つオブジェクトを返しています。
ここでいう投稿データは、slug
とtitle
のプロパティを持つオブジェクトの配列です。
このコードを書くことで、サーバーサイドでデータを取得し、クライアントサイドに提供することができます。loader関数は、ページが読み込まれる前に実行され、必要なデータを非同期的に取得します。
const { posts } = useLoaderData();
console.log(posts);
return (
<main>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link
to={post.slug}
className="text-blue-6 underline"
>
{post.title}
</Link>
</li>
))}
</ul>
</main>
);
このコードではuseLoaderData
フックを使用して、loader
関数から返された投稿データをposts
変数に取得しています。
分割代入を使用して、posts
配列を抽出しています。
map
関数を使用して投稿データをli
要素にマッピングし、各投稿のタイトルをリンクとして表示しています。
ここで
key={post.slug}>
とありますがこれは、Reactのkey
属性にpost.slug
の値を設定しています。key
属性は、リストをレンダリングする際に、各要素を一意に識別するために使用されます。
このコードにより、Remixアプリケーション内で投稿一覧ページが表示されます。
loader関数で定義されたサンプルデータに基づいて、"My First Post"と"A Mixtape I Made Just For You"という2つの投稿が表示されます。
各投稿のタイトルをクリックすると、それぞれの投稿の個別ページに遷移します。
しかしLink
要素でto={post.slug}
の遷移先のページは作っていないので遷移しても404と出ます。
リファクタリング
ここでリファクタリングを行います。リファクタリングを行うことで可読性の向上が見込めます。
サーバファイルの追加
まずデータを返すモジュール app/models/post.server.ts
を作成します。
touch 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",
},
];
}
コードの説明
type Post ={ slug: string title: string; };
type Post = { slug: string; title: string; };
は、TypeScriptでPost
という名前の型エイリアスを定義しています。
型エイリアスは、既存の型に別名をつけるために使用されます。
export async function getPosts(): Promise<Array<Post>>
export async function getPosts(): Promise<Array<Post>>
は、非同期関数getPosts
を定義し、Promise<Array<Post>>
型の値を返すことを宣言しています。
これにより、非同期的にブログの投稿データを取得し、取得したデータを使って further な処理を行うことができます。
posts._index.tsxの修正
app/route/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() });
};
export default function Posts() {
const { posts } = useLoaderData();
console.log(posts);
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>
);
}
ここで
import { getPosts } from "~/models/post.server";
が追加されています。
追加した理由は、先ほど作成したapp/models/post.server.ts
で定義した関数 getPosts
をインポートするためです。
また
return json({ posts: await getPosts() });
と書くことでapp/models/post.server.ts
内のgetPosts
関数から取得した投稿データを JSON 形式でレスポンスとして返しています。
これで app/route/posts._index.tsx
で getPosts
からデータを呼ぶようにしました。
このようなリファクタリングを行うことで、コードの構造が整理され、投稿データの取得ロジックを再利用しやすくなります。
ただし、リファクタリングを行う際には、既存の機能を壊さないように注意し、適切なテストを行いながら進めることが重要です。
Prismaを使ってDBからデータを読み込む
まずPrismaについて軽く触れます。
Prisma とは、Node.js および TypeScript のためのオープンソースのデータベース ツールキットです。Prisma を使用することで、データベースとのやり取りを型安全かつ効率的に行うことができます。また、Prisma はデータベース マイグレーションの管理を容易にし、スキーマの変更に伴うデータベースの更新を自動化します。
ここはざっくりとした理解で大丈夫です
次にブログアプリを作成する過程で、Prismaを使ってDBからデータを読み込む方法について解説します。
スキーマの定義
まず、prisma/schema.prisma
ファイルに Post
モデルを定義します。
model Post {
slug String @id
title String
markdown String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
ここでは、slug
、title
、markdown
、createdAt
、updatedAt
を定義しています。
それぞれのフィールドの意味は以下の通りです。
-
slug
: 投稿の一意な識別子。-
@id
はこのフィールドが主キーであることを示します。
-
-
title
: 投稿のタイトルを表す文字列。 -
markdown
: 投稿の本文をMarkdown
形式で格納する文字列。 -
createdAt
: 投稿が作成された日時を表すDateTime
型のフィールド。-
@default(now())
は、新しいレコードが作成されたときに現在の日時をデフォルト値として設定することを示します。
-
-
updatedAt
: 投稿が最後に更新された日時を表すDateTime
型のフィールド。-
@updatedAt
は、レコードが更新されるたびに自動的に現在の日時に更新されることを示します。
-
これらの定義を通して対応するデータベーステーブルが作成され、Prisma Client
を介してデータの読み書きができるようになります。
DBの更新
次に、以下のコマンドを実行してDBにテーブルを作成します。
npx prisma db push
このコマンドは、Prisma スキーマの変更をデータベースに反映させることを目的に行います。
シードデータの追加
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,
});
}
このコードではPrisma を使用してデータベースに初期データを挿入するための処理を行っています。
具体的には、以下の処理が行われています。
まず初めに、2つの投稿オブジェクトを含む配列が定義されています。
各オブジェクトには、slug
(投稿のスラッグ)、title
(投稿のタイトル)、markdown
(投稿の本文)というプロパティが含まれています。
.trim()
は JavaScript の文字列メソッドで、文字列の先頭と末尾にある空白文字を削除するために使用されます。
末尾のコード(以下)は
for (const post of posts) {
await prisma.post.upsert({
where: { slug: post.slug },
update: post,
create: post,
});
}
posts
配列の各要素に対して、Prisma
の upsert
メソッドを使用してデータベースに投稿を保存または更新するための処理を行っています。
for...of
ループを使用して、posts
配列の各要素 ( post
)に対して以下の処理を行います
-
prisma.post.upsert
: このメソッドは、指定された条件に基づいて、データベース内の投稿を検索し、存在する場合は更新し、存在しない場合は新しく作成します。 -
where
:where
オプションで、slug
フィールドを指定しています。これは、投稿を一意に識別するための条件です。post.slug
の値を使用して、データベース内の対応する投稿を検索します。 -
update
:post
オブジェクトを指定しています。これは、投稿が既にデータベースに存在する場合に、その投稿を更新するためのデータです。 -
create
オプションでも、post
オブジェクトを指定しています。これは、投稿がデータベースに存在しない場合に、新しい投稿を作成するためのデータです。 -
await
:await
キーワードを使用して、prisma.post.upsert
メソッドの実行を待機します。これは、upsert
メソッドが非同期処理であるためです。await
を使用することで、upsert
メソッドの完了を待ってから次の投稿の処理に進みます。
以上のようにシードデータを追加したら、以下のコマンドでDBにデータを取り込みます。
npx prisma db seed
これでデータを取り込むことが出来ました。
マイグレーションファイルの生成
以下のコマンドを実行して、マイグレーションファイルを生成します。
マイグレーションファイルを生成する理由は、データベーススキーマの変更を管理し、バージョン管理することです。これにより、データベースの変更を追跡し、チーム内で共有することができます。
npx prisma migrate dev --name "create post model"
これで生成することが出来ました。
DBからデータを読み込む
app/models/post.server.ts
ファイルを更新し、DB(データベース)から投稿を読み込むようにします。このコードを記述することでDBからすべての投稿を取得することができます。
import { prisma } from "~/db.server";
export async function getPosts() {
return prisma.post.findMany();
}
コードの説明
import { prisma } from "~/db.server";
~/db.server
モジュールから prisma
オブジェクトをインポートしています。
export async function getPosts() { ... }
getPosts
という名前の非同期関数を定義し、エクスポートしています。
return prisma.post.findMany();
prisma.post.findMany()
メソッドを呼び出して、データベースからすべての投稿を取得します。取得された投稿は、関数の戻り値として返されます。
ここで注目したいのは、Prismaを使用することで手動で型定義をしなくてもpostの型が自動的に推論されている点です。
Prismaは、スキーマ定義に基づいて型安全なクエリを生成してくれるので、型の不一致によるエラーを防ぐことができます。
動的セグメントを使用したルートの作成
次に個々の投稿ページを作成する方法について解説します。
以下のようなURLで投稿ページにアクセスできるようにします。
/posts/my-first-post
/posts/90s-mixtape
各投稿ごとにルートを作成するのではなく、URLに「動的セグメント」を使用します。この場合、以下のデイレクトリ名の $slug
が動的セグメントを表しています。
Remixは動的セグメントを解析し、パラメータとして渡してくれるので、投稿を動的に取得することができます。
では app/routes/posts.$slug.tsx
に動的ルートを作成していきます。
touch app/routes/posts.$slug.tsx
これで動的ルートを得たデイレクトリを作成できました。
次にファイル内に投稿の詳細ページのレイアウトを定義し、タイトル "Some Post" を表示します。
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>
);
}
投稿のリンクをクリックすると、新しいページが表示されるはずです。
パラメータにアクセスするためのローダーの追加
パラメータにアクセスするためのローダーを追加します。
この作業を行うことでスラッグに基づいてデータベースからデータを取得したり、APIを呼び出したりすることで、動的なページを生成することができます。
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export const loader = async ({ params }: LoaderArgs) => {
return json({ slug: params.slug });
};
export default function PostSlug() {
const { slug } = useLoaderData<typeof loader>();
return (
<main className="mx-auto max-w-4xl">
<h1 className="my-6 border-b-2 text-center text-3xl">
Some Post: {slug}
</h1>
</main>
);
}
コードの説明
今回追加したコードの説明は以下の通りです。
import type { LoaderArgs } from "@remix-run/node";
LoaderArgs
型をインポートしています。これは、ローダー関数の引数の型を定義するために使用されます。
import { json } from "@remix-run/node";
json
関数をインポートしています。
import { useLoaderData } from "@remix-run/react";
これは、ローダー関数によって返されたデータにアクセスするために使用されます。
export const loader = async ({ params }: LoaderArgs) => {
return json({ slug: params.slug });
};
関数を追加しています。この関数は、params
オブジェクトからスラッグ ( slug
) パラメータを取得し、それをJSONレスポンスとして返します。
export default function PostSlug() {
const { slug } = useLoaderData<typeof loader>();
return () }:
コンポーネント内で、useLoaderData
フックを使用してローダー関数によって返されたデータにアクセスしています。 typeof loader
を使用して、ローダー関数の戻り値の型を推論しています。
Some Post: {slug}
取得したスラッグ ( slug
) を <h1>
タグ内で表示しています。
これらの変更により、動的セグメントから取得したパラメータを使用して、ページの内容を動的に生成することができるようになります。
例えば、 /posts/example-post
というURLにアクセスすると、loader
関数が呼び出され、params.slug
に "example-post"
が設定されます。その後、PostSlug
コンポーネントがレンダリングされ、useLoaderData
フックを通じてスラッグにアクセスできます。最終的に、<h1>
タグ内で "Some Post: example-post"
のように表示されます。
DBから投稿内容を取得
次に、slug
を使ってデータベースから実際の投稿内容を取得しましょう。
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
という新しい非同期関数が追加されています。この関数は、特定の投稿を取得するために使用されます。
getPost
関数は以下のような特徴を持っています。
export async function getPost(slug: string) {}
slug
という文字列型のパラメータを受け取ります。これは、投稿を一意に識別するためのスラッグです。
return prisma.post.findUnique()
prisma.post.findUnique
メソッドを使用して、与えられたスラッグに一致する投稿を取得します。
findUnique({ where: { slug } })
findUnique
メソッドには、 where
句を指定するオブジェクトを渡します。ここでは、slug
プロパティを使用して、一致する投稿を検索します。一致する投稿が見つかった場合、その投稿データが返されます。見つからない場合は、null
が返されます。
この getPost
関数を使用することで、特定のスラッグに対応する投稿をデータベースから取得することができます。
getPost関数の追加
ルートで新しい getPost
関数を使用します。以下のコードを追加することで動的なルーティングに基づいて、個別の投稿ページを表示することができます。
URLのパラメータからスラッグを取得し、それを使用してデータベースから投稿データを取得し、取得したデータを使用してページの内容を動的に生成しています。
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
// 追加
import { getPost } from "~/models/post.server";
export const loader = async ({ params }: LoaderArgs) => {
// 追加
const post = await getPost(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>
);
}
コードの説明
import { getPost } from "~/models/post.server";
getPost
関数を "~/models/post.server"
からインポートしています。
export const loader = async ({ params }: LoaderArgs) => {
const post = await getPost(params.slug);
return json({ post });
};
loader
関数内で、getPost
関数を呼び出しています。params.slug
をパラメータとして渡し、対応する投稿を取得します。
const { post } = useLoaderData<typeof loader>()
取得した投稿データを post
変数に代入し、 json
関数を使用してレスポンスとして返しています。これにより、クライアントサイドで投稿データにアクセスできるようになります。
export default function PostSlug() {}
PostSlug
コンポーネント内で、useLoaderData
フックを使用してローダー関数によって返されたデータにアクセスしています。typeof loader
を使用して、ローダー関数の戻り値の型を推論しています。
{post.title}
取得した投稿データの title
プロパティを使用して、<h1>
タグ内に投稿のタイトルを表示しています。
このように投稿内容をJavaScriptとしてブラウザに含めるのではなく、データソースから取得するようになりました。
さらに、動的なルーティングと投稿データの取得が連携し、個別の投稿ページを表示できるようになります。
TypeScriptの型エラーを解消
先ほどの状態ではいくつかTSによる指摘がありました。
それを invariant()
を使用し params.slug
や post
の検証を追加しました。
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>
);
}
コードの説明
import invariant from "tiny-invariant";
tiny-invariant
は、条件が満たされない場合に例外をスローするための小さなユーティリティ関数です。 loader
関数内で、 invariant
関数を使用して、 params.slug
が存在することを確認しています。存在しない場合は、例外がスローされます。
ここでスローについての説明をパパっとしておきます。「スロー」は例外を投げるという意味で使われ、例外が発生したことを示すために、 throw
文を使用して例外オブジェクトを投げます。
invariant(params.slug, "params.slug is required");
loader
関数内で、params.slug
が存在することを確認するために invariant
関数が追加されました。 params.slug
が存在しない場合、"params.slug is required"
というメッセージとともに例外がスローされます。
invariant(post, `Post not found: ${params.slug}`);
getPost
関数を呼び出した後、post
が存在することを確認するために invariant
関数が追加されました。
post
が存在しない場合、"Post not found: ${params.slug}"
というメッセージとともに例外がスローされます。
これらのエラーチェックにより、アプリケーションの堅牢性が向上し、不正な状態に対する適切なエラーハンドリングが行われるようになります。
マークダウンをHTMLに変換
マークダウンをパースしてHTMLにレンダリングしてページに表示しましょう。マークダウンパーサーはたくさんありますが、このチュートリアルでは marked
を使用します。簡単に動作させられるからです。
マークダウンをHTMLに変換します。まずはmarkedをインストールします。
npm add marked
# TypeScriptを使用している場合は追加で以下を実行
npm add @types/marked -D
marked
をインストールしたら、サーバーを再起動する必要があります。開発サーバーを停止し、npm run dev
で再び起動してください。
再起動したのち 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 { marked } from "marked";
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}`);
//追加
const html = marked(post.markdown);
//追加
return json({ post, html });
};
export default function PostSlug() {
//追加
const { post, html } = 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>
//追加
<div dangerouslySetInnerHTML={{ __html: html }} />
</main>
);
}
コードの説明
import { marked } from "marked";
marked
は、 Markdown
テキストをHTMLに変換するためのライブラリです。
const html = marked(post.markdown);
marked
関数を使用して、投稿のMarkdownテキスト ( post.markdown
) を HTMLに変換しています。変換された HTML は html
変数に代入されます。
return json({ post, html });
json
関数を使用して、投稿データ ( post
) とHTMLデータ ( html
) の両方をレスポンスとして返しています。
export default function PostSlug() {}
PostSlug
コンポーネント内で、useLoaderData
フックを使用して、ローダー関数によって返された post
と html
のデータにアクセスしています。
- const { post, html } = useLoaderData<typeof loader>();
: PostSlug
コンポーネント内で、useLoaderData
フックの分割代入でHTMLも取得するように変更しました。
<div dangerouslySetInnerHTML={{ __html: html }} />
新しく追加された <div>
タグ内で、 dangerouslySetInnerHTML
属性を使用して、変換された HTML
をレンダリングしています。これにより、投稿の本文が HTML
として表示されます。
この変更により、投稿の本文がMarkdown形式からHTML形式に変換され、適切にレンダリング(表示)されるようになります。また、エラーハンドリングも強化されています。
これでブログができました。確認してみてください。
この章では loader
関数でデータを取得し、json
関数でJSONに変換してクライアントに送信します。クライアント側では、useLoaderData
フックを使ってデータを取得し、コンポーネントを描画する流れを行いました。
これにより、以下のようなメリットがあります。
- データをHTMLに埋め込む必要がないため、クライアント側でデータを簡単に利用できる。
- 必要なデータだけを送信するので、ページの読み込みが高速になる。
- サーバー側とクライアント側でコードを分離できるため、保守性が向上する。
従来のWebアプリケーションでは、サーバー側でHTMLを生成してクライアントに送信するか、すべてのデータをJavaScriptとしてブラウザに含めるかのどちらかでした。
Remixではこれらとは異なるアプローチを取ることで、高速で使いやすいWebアプリケーションを構築することができます。
ブログ投稿の管理画面を作ろう
Remixを使ってブログアプリを作る際、投稿の管理画面を作成することが重要です。ここでは、管理画面の作成手順を簡潔に説明します。
管理画面へのリンクを追加
まず、投稿一覧ページに管理画面へのリンクを追加します。
<Link to="admin" className="text-red-600 underline">
Admin
</Link>
管理画面用のルートを作成
次に、app/routes/posts.admin.tsx
ファイルを作成し、管理画面用のルートを定義します。
touch 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>
);
}
ここでは、投稿一覧を左側に、プレースホルダーを右側に表示するようにしています。
今までのコードをよく読んでいたら、何をしているのか分かってきたでしょうか??
このまま頑張りましょう!
インデックスルートを作成
次に管理画面のプレースホルダーを、インデックスルートで置き換えます。
置き換えることで、管理画面のURLがシンプルになり、コードの構造化、権限管理の簡略化、スケーラビリティの向上、ユーザーエクスペリエンスの改善が図れます。
admin
ディレクトリを作成し、その中にインデックスルートを作成します。
mkdir app/routes/posts/admin
touch 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>
);
}
アウトレット(Outlet)を追加
管理画面のレイアウトにアウトレットを追加し、子ルートを表示できるようにします。
アウトレット(Outlet)を追加する主な理由は、以下の2点です。
- 親ルートのレイアウト内で子ルートを表示するためのプレースホルダーとして機能します。
- ネストされたルート構造を実現し、親ルートと子ルートの間でレイアウトを共有・再利用できます。
これにより、コードの整理、構造化、および一貫したユーザーエクスペリエンスの提供が可能になります。
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>
);
}
今回追加した具体的なコードの例では、 posts.admin.tsx
ファイルに Outlet
を追加することで、ブログ管理画面の親ルートを定義し、その中で子ルートを表示する場所を指定しています。
アウトレットを使用することで、親ルートと子ルートの間でコードを分離し、共通のレイアウトを再利用しながら、それぞれの機能を独立して開発することができます。
新規投稿ページを作成
最後に、新規投稿ページ用のルートを作成します。
touch app/routes/posts.admin.new.tsx
export default function NewPost() {
return <h2>New Post</h2>;
}
これで、管理画面からリンクをクリックすると、新規投稿ページが表示されるようになります。
以上が、Remixでブログ投稿の管理画面を作成する手順です。Remixのルーティングの仕組みを活用することで、シンプルで分かりやすい管理画面を作ることができます。
Remixのルーティングとアウトレットの仕組みについて、もう少し詳しく説明します。
Remixでは、ルートの構造がそのままUIの構造に反映されます。つまり、ルートの親子関係がそのままコンポーネントの親子関係になります。
例えば、/posts/admin
というルートがあり、その下に index.tsx
と new.tsx
というファイルがあるとします。
/posts/admin/index.tsx
/posts/admin/new.tsx
/posts/admin.tsx
この場合、/posts/admin.tsx
がレイアウトコンポーネントの役割を果たし、<Outlet />
コンポーネントを含んでいます。<Outlet />
は、子ルートのコンポーネントをレンダリングする場所を示しています。
そして、/posts/admin
にアクセスすると、/posts/admin/index.tsx
のコンポーネントが <Outlet />
の部分にレンダリングされます。一方、/posts/admin/new
にアクセスすると、/posts/admin/new.tsx
のコンポーネントが <Outlet />
の部分にレンダリングされます。
この時、/posts/admin.tsx
のヘッダーやナビゲーション部分は変化せず、<Outlet />
の部分だけが更新されます。これは、Remixがルートの変更を検知し、必要な部分だけを再レンダリングしているためです。
このように、Remixではルートとコンポーネントの構造が密接に関係しており、ルートの変更に応じて必要な部分だけが更新されます。これにより、ページ遷移の際に必要なデータだけをサーバーから取得し、クライアントに送信することができます。
これは、Remixの哲学である「ネットワークに送信するデータ自体を減らす」という戦略に沿っています。従来のSPAでは、ページ遷移の度に大量のJavaScriptを読み込む必要がありましたが、Remixではサーバーサイドでレンダリングを行い、必要なデータだけをクライアントに送信することで、ネットワークの負荷を軽減しています。
また、Remixではルートごとにデータの取得やバリデーションのロジックを記述することができます。これにより、コンポーネントはUIの表示に専念でき、データの取得や処理はルートが担当するという、関心の分離が実現されています。
以上のように、Remixのルーティングとアウトレットの仕組みは、パフォーマンスの向上と開発の効率化に大きく貢献しています。Remixを使うことで、高速で保守性の高いWebアプリケーションを開発することができます。
ブログ投稿フォームを作ろう
Remixを使ってブログアプリを作る際、投稿フォームを作成することが重要です。ここでは、投稿フォームの作成手順と、各部分がどのように関係し機能しているかを説明します。
まず、app/routes/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>
);
}
ここでは、Remixの Form
コンポーネントを使用しています。Form
コンポーネントは、HTMLの form
要素をラップしており、Remixのアクションと連携することができます。
method="post"
は、フォームが送信された際に、POSTリクエストを送信することを指定しています。
投稿を保存するアクションを作成
次に、app/models/post.server.ts
ファイルに投稿を保存するアクションを作成します。
export async function createPost(post) {
return prisma.post.create({ data: post });
}
この関数は、引数で渡された post
オブジェクトを使用して、データベースに新しい投稿を作成します。ここでは、Prismaを使用してデータベースにアクセスしています。
そして、app/routes/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");
};
// ...
ここでまたTypeScriptの構文でエラーが出るので型を追加します。
// ...
//追加
import type { Post } from "@prisma/client";
// ...
export async function createPost(
//追加
post: Pick<Post, "slug" | "title" | "markdown">
) {
return prisma.post.create({ data: post });
}
TypeScriptを使用しているかどうかにかかわらず、ユーザーがこれらのフィールドの一部に値を提供しない場合に問題が発生します。
投稿を作成する前に、いくつかの検証を追加
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
フックを使用してコンポーネントで利用できます。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>
);
}
ここでは、useActionData
フックを使用して、アクションから返されたエラーを取得しています。エラーがある場合は、各フィールドの横にエラーメッセージを表示します。
ただし、TypeScriptはまだ不満があります。誰かが文字列以外の値で我々のAPIを呼び出す可能性があるためです。そこで、TypeScriptを満足させるために、いくつかの不変条件を追加しましょう。
APIリクエストの検証のため、action にも invariant を追加
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");
};
ここでは、invariant
関数を使用して、title
、slug
、markdown
が文字列であることを保証しています。これらの値が文字列でない場合、invariant
関数はエラーを投げます。
これにより、TypeScriptのエラーが解消され、アクションの引数が正しい型であることが保証されます。
このように、Remixではアクションとバリデーションが密接に連携しています。アクションでエラーを返すことで、フォームの送信後に検証エラーを表示することができます。また、TypeScriptと invariant
関数を組み合わせることで、型の安全性を確保しながらアクションを実装することができます。
UX向上のための機能追加
ここでは、ユーザーエクスペリエンス(UX)を向上させるための施策として、以下の2つの機能を追加します。
- フォームの送信時に人工的な遅延を導入する
- フォームの送信中にボタンの状態を変更する
フォームの送信時に人工的な遅延を導入
app/routes/posts.admin.new.tsx
ファイルの action
関数内に、以下のコードを追加します。
// ...
export const action = async ({ request }: ActionArgs) => {
//追加
// TODO: remove me
await new Promise((res) => setTimeout(res, 1000));
// ...
};
// ...
ここでは、action
関数内に人工的な遅延を導入しています。
具体的には、setTimeout
関数を使用して、1000ミリ秒(1秒)の遅延を発生させています。この遅延は、フォームの送信処理が完了するまでの間、ユーザーにロード中の状態を表示するために使用されます。
※注意:この遅延は、実際のアプリケーションでは必要ありません。ここでは、ユーザーに対してフォームの送信中であることを示すために、あえて遅延を導入しています。
フォームの送信中にボタンの状態を変更
次に、app/routes/posts.admin.new.tsx ファイルの NewPost コンポーネント内で、useTransition フックを使用して、フォームの送信状態を管理します。
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>
);
}
ここでは、useTransition
フックを使用して、フォームの送信状態を取得しています。useTransition
フックは、現在のトランジションの状態を表すオブジェクトを返します。このオブジェクトの submission
プロパティは、フォームの送信中である場合に true
になります。
isCreating
変数は、transition.submission
の値を Boolean
関数で真偽値に変換したものです。この変数を使用して、フォームの送信中かどうかを判断します。
ボタンの disabled
属性に isCreating
変数を指定することで、フォームの送信中はボタンが無効になります。また、ボタンのテキストも isCreating
変数の値に応じて、"Creating..."または"Create Post"に切り替わります。
これにより、ユーザーはフォームの送信中にボタンが無効になり、テキストが変化することで、現在の状態を視覚的に認識することができます。
以上が、Progressive Enhancementの説明になります。これらの機能を追加することで、ユーザーにとってよりわかりやすく、スムーズなフォーム送信の体験を提供することができます。
おわりに
本記事では、Remixを使ったブログアプリの作成方法について、段階的に解説してきました。
まず、Remixアプリケーションのセットアップから始まり、ルーティングの設定、データの取得と表示、フォームの作成、データベースとの連携、Progressive Enhancementの実装など、一連の流れを通して、Remixでのアプリケーション開発の基本的な概念と手法を学びました。
Remixは、React をベースにした最新のWebフレームワークであり、サーバーサイドレンダリングとクライアントサイドルーティングを組み合わせたアーキテクチャを採用しています。このアーキテクチャにより、高速で使いやすいWebアプリケーションの開発が可能になります。
また、Remixは以下のような特徴と利点を持っています。
- ネットワークリクエストの最適化: Remixは、データの取得とコンポーネントの描画を効率的に行うことで、高速なページロードを実現します。
- コードの自動分割: Remixは、コードを自動的に分割し、必要なコンポーネントのみを読み込むことで、アプリケーションの初期ロード時間を短縮します。
- 型安全性: TypeScriptとの緊密な統合により、型安全なコードを書くことができ、開発時のエラーを防ぐことができます。
- 柔軟なデータ取得: loader 関数と useLoaderData フックを使用することで、サーバーサイドでデータを取得し、クライアントに渡すことができます。
- シームレスなフォーム処理: action 関数と useActionData フックにより、フォームの送信とデータの処理を簡単に行うことができます。
- プログレッシブエンハンスメント: Remixは、プログレッシブエンハンスメントの原則に基づいて設計されており、ユーザーエクスペリエンスを向上させるための機能を追加することができます。
本記事で紹介した内容は、Remixの基本的な使い方の一部ですが、Remixの強力な機能と開発体験の良さを感じていただけたのではないでしょうか。
Remixは、モダンなWebアプリケーション開発に必要な機能を網羅しつつ、シンプルで使いやすいAPIを提供しています。これにより、開発者はアプリケーションのロジックに集中することができ、生産性を向上させることができます。
Remixは、Webアプリケーション開発の未来を切り開く可能性を秘めたフレームワークです。今後もRemixの発展に注目していきたいと思います。
最後に、Remixを使ったブログアプリ開発チュートリアルを通して、Remixの基本的なコンセプトと使い方を学んでいただけたことを願っています。Remixの公式ドキュメントやコミュニティの情報を参考に、さらに理解を深めていくことをお勧めします。
Remixを使ったWebアプリケーション開発に挑戦してみてください。きっと、Remixの魅力を実感していただけるはずです。
Happy coding with Remix!