5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

初心者による初心者のためのRemixチュートリアル

Last updated at Posted at 2024-03-17

はじめに

こんにちは!今回は、モダンなWeb開発フレームワークであるRemixを使って、ブログアプリケーションを作成する方法を紹介します。

Remixは、React用のフレームワークで、サーバーサイドレンダリングとクライアントサイドルーティングを組み合わせた効率的なアプリケーション開発を可能にします。

私自身プログラミング言語やRemixを学習し始めて1か月程度なので至らぬ文ではありますが、誰かの役に立てれば幸いです:grin:

この記事では、Remixの基本的なコンセプトや機能を説明しながら、実際にブログアプリケーションを構築していきます。初めてRemixを触る方にもわかりやすいように、コードの説明を詳細に行っていますので、ぜひ参考にしてみてください。

公式のBlog Tutorialは以下です。

それでは、始めていきましょう!

:cd: インストール :cd:

まずはRemixをインストールしましょう。
テキストエディタ(今回私はVisual Studio Codeを使用しました。)を開き、自分の指定したフォルダを開き、Terminalで以下のコマンドを打ちましょう!

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

このとき3つの質問を投げられますが、すべてYesで大丈夫です。

:cd: Remixのデイレクトリ構造 :cd:

ここでインストールした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の設定ファイルです。

以上がざっくりとしたデイレクトリ構造です!
ここら辺は触っていくうちに自然と掴んでくるので流し見で大丈夫です:thumbsup:

:cd: ルートの作成 :cd:

Remixでは、先ほども説明したように app/routes ディレクトリ内のファイルがルートとなります。
まずは、トップページ(app/routes/_index.tsx)に新しいページへのリンクを追加します。

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>

69行目に挿入してください。
スクリーンショット 2024-03-21 103206.png

:dvd: コードの説明 :dvd:

このコードでは、React組み込みのLinkコンポーネントを使って、ブログの投稿一覧ページへのリンクを作成しています。

Linkコンポーネントは、Remixアプリケーション内の他のページへ遷移するためのリンクを作成するために使用されます。to属性で遷移先のパスを指定します。
div要素とLink要素には、Tailwind CSSのクラス名が適用されています。
Tailwind CSSのClass名は以下のリンクを参照してみてください!:snowflake:

<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: 開発用サーバーの起動 :cd:

次に、開発用サーバーを起動して、アプリケーションを確認しましょう。
プロジェクトのルートディレクトリに移動し、以下のコマンドを実行します。

cd blog-tutorial
npm run dev

npm run dev を実行すると以下のように http://localhost:3000 が立ち上がるのでクリックしてブラウザで確認してみてください!
スクリーンショット 2024-03-19 233825.png
スクリーンショット 2024-03-21 103319.png

ブラウザでは、"Blog Posts"というテキストのリンクがページの中央に表示されています。リンクの文字サイズはやや大きめ(xl)で、青色(blue-600)で下線が引かれています。

クリックすると、/postsというURLパスを持つページに遷移します。

:cd: 投稿一覧ページの作成 :cd:

次に、ブログ記事の一覧を表示するページを作成します。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 ルートに対応するインデックスルートであることが一目で分かります。

詳しくは下記の記事を参照してみてください。

では作成したファイルに下記のコードを書いてみましょう!

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

:dvd: コードの説明 :dvd:

  • export defaultは、JavaScriptのモジュールシステムにおいて、そのファイルから他のファイルへデフォルトでエクスポートする要素を指定するための構文です。
    一つのファイルにつき、export defaultは一つしか使えません。
  • functionは、JavaScriptの関数を定義するためのキーワードです。関数は、特定のタスクを実行したり、値を計算して返したりするための再利用可能なコードブロックです。

※ここで定義している関数は"Posts"です。

export default function

を組み合わせると、次のような意味になります。

  • このファイルから、指定された関数"Posts"をデフォルトでエクスポートします。
  • 他のファイルから、このファイルをインポートする際、export defaultで指定された関数"Posts"が自動的にインポートされます。
  • インポート側のファイルでは、任意の名前を使ってこの関数"Posts"を参照できます。

このように export defaultを使うことで、他のファイルからコンポーネントを簡単に再利用できるようになります。
これは、Reactアプリケーションの構築において非常に重要な概念です。

このファイルを作成することで、先ほど作成したリンク先を確認すると、"Posts"というタイトルが表示されるはずです。
スクリーンショット 2024-03-21 103550.png

:cd: データの読み込み :cd:

Remixでは、loader関数を使ってデータを読み込むことができます。app/routes/posts._index.tsxに以下のコードを追加します。

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>
  );
}

:dvd: コードの説明 :dvd:

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というキーに投稿データの配列を持つオブジェクトを返しています。

ここでいう投稿データは、slugtitleのプロパティを持つオブジェクトの配列です。

このコードを書くことで、サーバーサイドでデータを取得し、クライアントサイドに提供することができます。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アプリケーション内で投稿一覧ページが表示されます。
スクリーンショット 2024-03-21 113209.png

loader関数で定義されたサンプルデータに基づいて、"My First Post"と"A Mixtape I Made Just For You"という2つの投稿が表示されます。
各投稿のタイトルをクリックすると、それぞれの投稿の個別ページに遷移します。

しかしLink要素でto={post.slug}の遷移先のページは作っていないので遷移しても404と出ます。

:cd: リファクタリング :cd:

ここでリファクタリングを行います。リファクタリングを行うことで可読性の向上が見込めます。

:hammer_pick: サーバファイルの追加 :hammer_pick:

まずデータを返すモジュール app/models/post.server.ts を作成します。

touch app/models/post.server.ts

先ほど書いたコードの一部を書き写します。

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",
    },
  ];
}

:dvd: コードの説明 :dvd:

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 な処理を行うことができます。

:hammer_pick: posts._index.tsxの修正 :hammer_pick:

app/route/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.tsxgetPosts からデータを呼ぶようにしました。

このようなリファクタリングを行うことで、コードの構造が整理され、投稿データの取得ロジックを再利用しやすくなります。
ただし、リファクタリングを行う際には、既存の機能を壊さないように注意し、適切なテストを行いながら進めることが重要です。

:cd: Prismaを使ってDBからデータを読み込む :cd:

まずPrismaについて軽く触れます。
Prisma とは、Node.js および TypeScript のためのオープンソースのデータベース ツールキットです。Prisma を使用することで、データベースとのやり取りを型安全かつ効率的に行うことができます。また、Prisma はデータベース マイグレーションの管理を容易にし、スキーマの変更に伴うデータベースの更新を自動化します。
ここはざっくりとした理解で大丈夫です:thumbsup:

次にブログアプリを作成する過程で、Prismaを使ってDBからデータを読み込む方法について解説します。

:hammer_pick: スキーマの定義 :hammer_pick:

まず、prisma/schema.prisma ファイルに Post モデルを定義します。

prisma/schema.prisma
model Post {
  slug     String @id
  title    String
  markdown String

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

ここでは、slugtitlemarkdowncreatedAtupdatedAt を定義しています。

それぞれのフィールドの意味は以下の通りです。

  • slug : 投稿の一意な識別子。
    • @id はこのフィールドが主キーであることを示します。
  • title : 投稿のタイトルを表す文字列。
  • markdown: 投稿の本文を Markdown 形式で格納する文字列。
  • createdAt : 投稿が作成された日時を表す DateTime 型のフィールド。
    • @default(now()) は、新しいレコードが作成されたときに現在の日時をデフォルト値として設定することを示します。
  • updatedAt : 投稿が最後に更新された日時を表す DateTime 型のフィールド。
    • @updatedAt は、レコードが更新されるたびに自動的に現在の日時に更新されることを示します。

これらの定義を通して対応するデータベーステーブルが作成され、Prisma Client を介してデータの読み書きができるようになります。

:hammer_pick: DBの更新 :hammer_pick:

次に、以下のコマンドを実行してDBにテーブルを作成します。

npx prisma db push

このコマンドは、Prisma スキーマの変更をデータベースに反映させることを目的に行います。

:hammer_pick: シードデータの追加 :hammer_pick:

prisma/seed.ts ファイルにシードデータを追加します。

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 配列の各要素に対して、Prismaupsert メソッドを使用してデータベースに投稿を保存または更新するための処理を行っています。
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

これでデータを取り込むことが出来ました。

:cd: マイグレーションファイルの生成 :cd:

以下のコマンドを実行して、マイグレーションファイルを生成します。
マイグレーションファイルを生成する理由は、データベーススキーマの変更を管理し、バージョン管理することです。これにより、データベースの変更を追跡し、チーム内で共有することができます。

npx prisma migrate dev --name "create post model"

これで生成することが出来ました。

:hammer_pick: DBからデータを読み込む :hammer_pick:

app/models/post.server.ts ファイルを更新し、DB(データベース)から投稿を読み込むようにします。このコードを記述することでDBからすべての投稿を取得することができます。

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

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

:dvd: コードの説明 :dvd:

import { prisma } from "~/db.server";

~/db.server モジュールから prisma オブジェクトをインポートしています。

export async function getPosts() { ... }

getPosts という名前の非同期関数を定義し、エクスポートしています。

return prisma.post.findMany();

prisma.post.findMany() メソッドを呼び出して、データベースからすべての投稿を取得します。取得された投稿は、関数の戻り値として返されます。

ここで注目したいのは、Prismaを使用することで手動で型定義をしなくてもpostの型が自動的に推論されている点です。

Prismaは、スキーマ定義に基づいて型安全なクエリを生成してくれるので、型の不一致によるエラーを防ぐことができます。

:cd: 動的セグメントを使用したルートの作成 :cd:

次に個々の投稿ページを作成する方法について解説します。
以下のようなURLで投稿ページにアクセスできるようにします。

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

各投稿ごとにルートを作成するのではなく、URLに「動的セグメント」を使用します。この場合、以下のデイレクトリ名の $slug が動的セグメントを表しています。
Remixは動的セグメントを解析し、パラメータとして渡してくれるので、投稿を動的に取得することができます。
では app/routes/posts.$slug.tsx に動的ルートを作成していきます。

touch app/routes/posts.$slug.tsx

これで動的ルートを得たデイレクトリを作成できました。

次にファイル内に投稿の詳細ページのレイアウトを定義し、タイトル "Some Post" を表示します。

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>
  );
}

投稿のリンクをクリックすると、新しいページが表示されるはずです。

:hammer_pick: パラメータにアクセスするためのローダーの追加 :hammer_pick:

パラメータにアクセスするためのローダーを追加します。
この作業を行うことでスラッグに基づいてデータベースからデータを取得したり、APIを呼び出したりすることで、動的なページを生成することができます。

app/routes/posts.$slug.tsx
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>
  );
}

:dvd: コードの説明 :dvd:

今回追加したコードの説明は以下の通りです。

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" のように表示されます。

:hammer_pick: DBから投稿内容を取得 :hammer_pick:

次に、slug を使ってデータベースから実際の投稿内容を取得しましょう。
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 } });
}

:dvd: コードの説明 :dvd:

上記の追加されたコードの説明を行います。今回、 getPost という新しい非同期関数が追加されています。この関数は、特定の投稿を取得するために使用されます。

getPost 関数は以下のような特徴を持っています。

export async function getPost(slug: string) {}

slug という文字列型のパラメータを受け取ります。これは、投稿を一意に識別するためのスラッグです。

return prisma.post.findUnique()

prisma.post.findUnique メソッドを使用して、与えられたスラッグに一致する投稿を取得します。

findUnique({ where: { slug } })

findUnique メソッドには、 where 句を指定するオブジェクトを渡します。ここでは、slug プロパティを使用して、一致する投稿を検索します。一致する投稿が見つかった場合、その投稿データが返されます。見つからない場合は、null が返されます。

この getPost 関数を使用することで、特定のスラッグに対応する投稿をデータベースから取得することができます。

:cd: getPost関数の追加 :cd:

ルートで新しい getPost 関数を使用します。以下のコードを追加することで動的なルーティングに基づいて、個別の投稿ページを表示することができます。

URLのパラメータからスラッグを取得し、それを使用してデータベースから投稿データを取得し、取得したデータを使用してページの内容を動的に生成しています。

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 { 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>
  );
}

:dvd: コードの説明 :dvd:

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としてブラウザに含めるのではなく、データソースから取得するようになりました。

さらに、動的なルーティングと投稿データの取得が連携し、個別の投稿ページを表示できるようになります。

:cd: TypeScriptの型エラーを解消 :cd:

先ほどの状態ではいくつかTSによる指摘がありました。
それを invariant() を使用し params.slugpost の検証を追加しました。

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>
  );
}

:dvd: コードの説明 :dvd:

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}" というメッセージとともに例外がスローされます。

これらのエラーチェックにより、アプリケーションの堅牢性が向上し、不正な状態に対する適切なエラーハンドリングが行われるようになります。

:cd: マークダウンをHTMLに変換 :cd:

マークダウンをパースしてHTMLにレンダリングしてページに表示しましょう。マークダウンパーサーはたくさんありますが、このチュートリアルでは marked を使用します。簡単に動作させられるからです。
マークダウンをHTMLに変換します。まずはmarkedをインストールします。

npm add marked
# TypeScriptを使用している場合は追加で以下を実行
npm add @types/marked -D

marked をインストールしたら、サーバーを再起動する必要があります。開発サーバーを停止し、npm run dev で再び起動してください。
再起動したのち app/routes/posts.$slug.tsxにコードを追加します。

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>
  );
}

:dvd: コードの説明 :dvd:

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 フックを使用して、ローダー関数によって返された posthtml のデータにアクセスしています。
- 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アプリケーションを構築することができます。

:cd: ブログ投稿の管理画面を作ろう :cd:

Remixを使ってブログアプリを作る際、投稿の管理画面を作成することが重要です。ここでは、管理画面の作成手順を簡潔に説明します。

:hammer_pick: 管理画面へのリンクを追加 :hammer_pick:

まず、投稿一覧ページに管理画面へのリンクを追加します。

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

:hammer_pick: 管理画面用のルートを作成 :hammer_pick:

次に、app/routes/posts.admin.tsx ファイルを作成し、管理画面用のルートを定義します。

touch app/routes/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>
  );
}

ここでは、投稿一覧を左側に、プレースホルダーを右側に表示するようにしています。

今までのコードをよく読んでいたら、何をしているのか分かってきたでしょうか??
このまま頑張りましょう!

:cd: インデックスルートを作成 :cd:

次に管理画面のプレースホルダーを、インデックスルートで置き換えます。

置き換えることで、管理画面のURLがシンプルになり、コードの構造化、権限管理の簡略化、スケーラビリティの向上、ユーザーエクスペリエンスの改善が図れます。

admin ディレクトリを作成し、その中にインデックスルートを作成します。

mkdir app/routes/posts/admin
touch app/routes/posts/admin/index.tsx
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>
  );
}

:hammer_pick: アウトレット(Outlet)を追加 :hammer_pick:

管理画面のレイアウトにアウトレットを追加し、子ルートを表示できるようにします。
アウトレット(Outlet)を追加する主な理由は、以下の2点です。

  • 親ルートのレイアウト内で子ルートを表示するためのプレースホルダーとして機能します。
  • ネストされたルート構造を実現し、親ルートと子ルートの間でレイアウトを共有・再利用できます。

これにより、コードの整理、構造化、および一貫したユーザーエクスペリエンスの提供が可能になります。

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>
  );
}

今回追加した具体的なコードの例では、 posts.admin.tsx ファイルに Outlet を追加することで、ブログ管理画面の親ルートを定義し、その中で子ルートを表示する場所を指定しています。

アウトレットを使用することで、親ルートと子ルートの間でコードを分離し、共通のレイアウトを再利用しながら、それぞれの機能を独立して開発することができます。

:cd: 新規投稿ページを作成 :cd:

最後に、新規投稿ページ用のルートを作成します。

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

これで、管理画面からリンクをクリックすると、新規投稿ページが表示されるようになります。

以上が、Remixでブログ投稿の管理画面を作成する手順です。Remixのルーティングの仕組みを活用することで、シンプルで分かりやすい管理画面を作ることができます。

Remixのルーティングとアウトレットの仕組みについて、もう少し詳しく説明します。

Remixでは、ルートの構造がそのままUIの構造に反映されます。つまり、ルートの親子関係がそのままコンポーネントの親子関係になります。

例えば、/posts/admin というルートがあり、その下に index.tsxnew.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アプリケーションを開発することができます。

:cd: ブログ投稿フォームを作ろう :cd:

Remixを使ってブログアプリを作る際、投稿フォームを作成することが重要です。ここでは、投稿フォームの作成手順と、各部分がどのように関係し機能しているかを説明します。

まず、app/routes/posts.admin.new.tsx ファイルに投稿フォームを作成します。

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リクエストを送信することを指定しています。

:cd: 投稿を保存するアクションを作成 :cd:

次に、app/models/post.server.ts ファイルに投稿を保存するアクションを作成します。

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

この関数は、引数で渡された post オブジェクトを使用して、データベースに新しい投稿を作成します。ここでは、Prismaを使用してデータベースにアクセスしています。

そして、app/routes/posts.admin.new.tsx ファイルにアクションを呼び出すコードを追加します。

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の構文でエラーが出るので型を追加します。

app/models/post.server.ts
// ...
//追加
import type { Post } from "@prisma/client";

// ...

export async function createPost(
//追加
post: Pick<Post, "slug" | "title" | "markdown">
) {
  return prisma.post.create({ data: post });
}

TypeScriptを使用しているかどうかにかかわらず、ユーザーがこれらのフィールドの一部に値を提供しない場合に問題が発生します。

:cd: 投稿を作成する前に、いくつかの検証を追加 :cd:

app/routes/posts.admin.new.tsx にいくつかの検証を追加しましょう。

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 フックを使用してコンポーネントで利用できます。useActionDatauseLoaderData と似ていますが、データはフォームのPOST後のアクションから取得されます。

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

app/routes/posts.admin.new.tsx にバリデーションメッセージを追加します。

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を満足させるために、いくつかの不変条件を追加しましょう。

:hammer_pick: APIリクエストの検証のため、action にも invariant を追加 :hammer_pick:

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");
};

ここでは、invariant 関数を使用して、titleslugmarkdown が文字列であることを保証しています。これらの値が文字列でない場合、invariant 関数はエラーを投げます。

これにより、TypeScriptのエラーが解消され、アクションの引数が正しい型であることが保証されます。

このように、Remixではアクションとバリデーションが密接に連携しています。アクションでエラーを返すことで、フォームの送信後に検証エラーを表示することができます。また、TypeScriptと invariant 関数を組み合わせることで、型の安全性を確保しながらアクションを実装することができます。

:cd: UX向上のための機能追加 :cd:

ここでは、ユーザーエクスペリエンス(UX)を向上させるための施策として、以下の2つの機能を追加します。

  • フォームの送信時に人工的な遅延を導入する
  • フォームの送信中にボタンの状態を変更する

:hammer_pick: フォームの送信時に人工的な遅延を導入 :hammer_pick:

app/routes/posts.admin.new.tsx ファイルの action 関数内に、以下のコードを追加します。

app/routes/posts/admin/new.tsx
// ...
export const action = async ({ request }: ActionArgs) => {
 //追加
  // TODO: remove me
  await new Promise((res) => setTimeout(res, 1000));

  // ...
};
// ...

ここでは、action 関数内に人工的な遅延を導入しています。
具体的には、setTimeout 関数を使用して、1000ミリ秒(1秒)の遅延を発生させています。この遅延は、フォームの送信処理が完了するまでの間、ユーザーにロード中の状態を表示するために使用されます。

※注意:この遅延は、実際のアプリケーションでは必要ありません。ここでは、ユーザーに対してフォームの送信中であることを示すために、あえて遅延を導入しています。

:hammer_pick: フォームの送信中にボタンの状態を変更 :hammer_pick:

次に、app/routes/posts.admin.new.tsx ファイルの NewPost コンポーネント内で、useTransition フックを使用して、フォームの送信状態を管理します。

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>
  );
}

ここでは、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!:cd:

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?