【徹底解説】Remixのチュートリアル(short編)

【徹底解説】Remixのチュートリアル(short編)

RemixのチュートリアルはTypeScriptを使用して実装を進める形になっていますが、もちろんTypeScriptを使用せずJavaScriptのみで進めることも可能です。

自分はTypeScriptにそこまで慣れていないので、今回はJavaScriptを使用してチュートリアルを進めてみることにしました。

 

 

当記事はチュートリアルの翻訳記事ではないので、自分なりの解釈で進め、随時解説を加えております。
原文が気になる方はぜひ原文も読んでみてください。

前提条件

チュートリアル内にはGitpodの紹介もありますが、今回は自身のローカル環境にてチュートリアルを進めていくことにします。

ブログ執筆時点での使用端末は MacBook Pro で、OS は Monterey 12.5 になります。

また、RemixはReact.jsをベースにしたフルスタックのフレームワークなので、

  • JavaScriptの基本知識
  • React.jsの基本知識

がある方が対象となっております。

その他、Remix公式が定める前提条件が以下の通りです。

  • Node.js がインストールされていること(バージョン: 14.x.x または16.0.0以上)
  • npm(バージョン: 7以上)
  • コードエディタ (VSCode など)

チュートリアルの概要

このチュートリアルは0からアプリを作成するのではなく、Remix CLIの Stacks という機能を使ってある程度完成しているアプリを作成し、それに対して機能を追加していく形で進めていくことで「Remixの概要」を掴むことを目的としているようです。

Remixについてもっと詳しく知りたい場合は、Remixのチュートリアル(Long)をお試しください。

このチュートリアルでは以下のソースコードをテンプレートとして使用し、

 

こちらに対して

  • 投稿一覧ページ
  • 投稿詳細ページ
  • 投稿作成ページ

を追加しながらRemixの概要を掴んでいきます。

今回使用しているテンプレートでは Tailwild CSS がセットアップされており、ソースコード内でも使用されております。

手順

プロジェクトの作成

早速プロジェクトの作成をしていきましょう。前述の通り、テンプレートを利用してプロジェクトの環境を作成します。

ターミナルの任意のディレクトリにて、以下のコマンドを入力していきます(blog-tutorial の部分は任意の名前に変更可能です)。

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

インストールが始まるといくつか質問がされますが、以下の通り入力してJavaScriptの環境を構築します。

Need to install the following packages:
  create-remix@1.19.3
Ok to proceed? (y) y

? TypeScript or JavaScript? JavaScript

? Do you want me to run `npm install`? Yes

今回はテンプレートを使用しているため、 npm install が行われた後に続けて npx remix init が実行され、DBのセットアップなども同時に行ってくれます。

こちらの挙動についての詳細が知りたい方はRemixのドキュメントを確認してください。

 

 

これにて環境構築は完了となります。簡単ですね。

Remixのテンプレートは自身で作成することもできるため、よく使うものについてはついてはテンプレート化しておく事で効率良く開発が進められそうです。

投稿一覧ページを追加

まずは投稿一覧ページを作成することで、Remixでのページの追加方法を学んでいきます。

ページを作成する前に、これから作成するページへのリンクをトップページに追加しておきましょう。app/routes/_index.jsx に以下のコードを追加します。

<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>

任意の場所に追加できますが、チュートリアルの内容と同じになるように、トップページの画像と使用技術一覧の項目の間に設置しました。

早速サーバーを起動して画面を確認してみましょう。サーバーを起動するには、ターミナルにて npm run dev を入力します。

npm run dev

localhost:3000 にアクセスしてみて、以下の通り表示されていたらOKです。

import { Link } from "@remix-run/react";

import { useOptionalUser } from "~/utils";

export const meta = () => [{ title: "Remix Notes" }];

export default function Index() {
  const user = useOptionalUser();
  return (
    <main className="relative min-h-screen bg-white sm:flex sm:items-center sm:justify-center">
      <div className="relative sm:pb-16 sm:pt-8">
        <div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
          <div className="relative shadow-xl sm:overflow-hidden sm:rounded-2xl">
            <div className="absolute inset-0">
              <img
                className="h-full w-full object-cover"
                src="https://user-images.githubusercontent.com/1500684/157774694-99820c51-8165-4908-a031-34fc371ac0d6.jpg"
                alt="Sonic Youth On Stage"
              />

              <div className="absolute inset-0 bg-[color:rgba(254,204,27,0.5)] mix-blend-multiply" />
            </div>
            <div className="relative px-4 pb-8 pt-16 sm:px-6 sm:pb-14 sm:pt-24 lg:px-8 lg:pb-20 lg:pt-32">
              <h1 className="text-center text-6xl font-extrabold tracking-tight sm:text-8xl lg:text-9xl">
                <span className="block uppercase text-yellow-500 drop-shadow-md">
                  Indie Stack
                </span>
              </h1>
              <p className="mx-auto mt-6 max-w-lg text-center text-xl text-white sm:max-w-3xl">
                Check the README.md file for instructions on how to get this
                project deployed.
              </p>
              <div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
                {user ? (
                  <Link
                    to="/notes"
                    className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
                  >
                    View Notes for {user.email}
                  </Link>
                ) : (
                  <div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0">
                    <Link
                      to="/join"
                      className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
                    >
                      Sign up
                    </Link>
                    <Link
                      to="/login"
                      className="flex items-center justify-center rounded-md bg-yellow-500 px-4 py-3 font-medium text-white hover:bg-yellow-600"
                    >
                      Log In
                    </Link>
                  </div>
                )}
              </div>
              <a href="https://remix.run">
                <img
                  src="https://user-images.githubusercontent.com/1500684/158298926-e45dafff-3544-4b69-96d6-d3bcc33fc76a.svg"
                  alt="Remix"
                  className="mx-auto mt-16 w-full max-w-[12rem] md:max-w-[16rem]"
                />
              </a>
            </div>
          </div>
        </div>

        <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>

        <div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-8">
          <div className="mt-6 flex flex-wrap justify-center gap-8">
            {[
              {
                src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg",
                alt: "Fly.io",
                href: "https://fly.io",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157764395-137ec949-382c-43bd-a3c0-0cb8cb22e22d.svg",
                alt: "SQLite",
                href: "https://sqlite.org",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg",
                alt: "Prisma",
                href: "https://prisma.io",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg",
                alt: "Tailwind",
                href: "https://tailwindcss.com",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg",
                alt: "Cypress",
                href: "https://www.cypress.io",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg",
                alt: "MSW",
                href: "https://mswjs.io",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg",
                alt: "Vitest",
                href: "https://vitest.dev",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png",
                alt: "Testing Library",
                href: "https://testing-library.com",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg",
                alt: "Prettier",
                href: "https://prettier.io",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg",
                alt: "ESLint",
                href: "https://eslint.org",
              },
              {
                src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg",
                alt: "TypeScript",
                href: "https://typescriptlang.org",
              },
            ].map((img) => (
              <a
                key={img.href}
                href={img.href}
                className="flex h-16 w-32 justify-center p-1 grayscale transition hover:grayscale-0 focus:grayscale-0"
              >
                <img alt={img.alt} src={img.src} className="object-contain" />
              </a>
            ))}
          </div>
        </div>
      </div>
    </main>
  );
}

続けて app/routes/posts._index.jsx を作成し、

touch app/routes/posts._index.jsx

内容を以下の通り編集しておきます。

export default function Posts() {
  return (
    <main>
      <h1>Posts</h1>
    </main>
  );
}

localhost:3000/posts にアクセスし、Posts という文字だけのページが表示されれば成功です。Remixを使って新しいページを作成することができました。

このように、Remixではファイルを追加するだけで新しいページを追加することができます。

こういった「ルールに従ったファイルを作成することで自動的にURLのパスが作成される機能」のことをファイルベースルーティングといいます。

Remixにおける命名規則の詳細はRoute File Naming(v2)を確認してください。

投稿一覧ページの完成

現在の投稿一覧ページは「Posts」という文字しか表示されていませんが、本来であればバックエンドAPIを介してデータベースなどから情報を取得し、その情報を画面に表示したいです。

Remixにおける、画面表示のためのバックエンドAPIの追加にはloader関数を定義します。 例えば、投稿一覧ページにおけるバックエンドAPIを追加するにはapp/routes/posts._index.jsx を以下の様に修正します。

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

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

export default function Posts() {
  const { posts } = useLoaderData();
  return (
    <main>
      <h1>Posts</h1>
    </main>
  );
}

追加したバックエンドAPIの情報を取得するためには、フロントエンド側のコードにてuserLoaderDataというカスタムフックを使用します。上記のコードでは、バックエンドAPIのデータからpostsの中身を取り出し、定数postsへと代入しています。

 

useLoaderData

続けてapp/routes/posts._index.jsx の内容を以下のように修正し、バックエンドから取得した情報を画面に反映してみます。

import { json } from "@remix-run/node";
// Linkコンポーネントをインポート
import { Link, useLoaderData } from "@remix-run/react";

// ...

export default function Posts() {
  const { posts } = useLoaderData();
	// バックエンドのデータを使う形に修正
  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>
  );
}

localhost:3000/posts にアクセスしてみると、以下のようにバックエンドから送られてきたデータが画面に表示されます。

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

ところで、routes内のコードが煩雑になりつつあるため、責務に応じて処理をファイル分けた方がいいかもしれません。

通常、データに関する振る舞いはモデルが担うため、app/models ディレクトリに app/models/post.server.js を追加する形で対応しましょう。

touch app/models/post.server.js

app/routes/posts._index.jsx が持つloader関数の中身をそのまま反映したgetPosts関数を作成し、そちらを呼び出す様に修正を加えます。

// app/models/post.server.js

export async function getPosts() {
  return [
    {
      slug: "my-first-post",
      title: "My First Post",
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You",
    },
  ];
}
// app/routes/posts._index.jsx

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

// 内容を変更
export const loader = async () => {
  return json({ posts: await getPosts() });
};

これにて、モデルから受け取ったデータを使って投稿一覧画面が完成しました。

getPosts関数 のインポート時に ~ という文字を使用しました。

これは tsconfig.json 内で設定されている値で、今回の場合は「import時に app/* の参照を簡単にするため」に使用されています。

参照:

💡 app/modes/ 以下の命名に .server という拡張子をつけました。

これによってJavaScriptのコードをブラウザ用にバンドルするときに、ファイル名にこの拡張子を含むファイルのモジュールやそのインポートを無視にする様にコンパイラに指示することができます。

参照: https://remix.run/docs/en/main/guides/constraints#no-module-side-effects

Prismaを利用したDatabaseの使用

テンプレートを用いて環境構築をしたので、このプロジェクトではすでにSQLiteおよびPrismaがセットアップされています。

投稿一覧ページに表示する内容をデータベースから取得したデータにするために、いくつか変更を加えます。

prisma/schema.prisma の末尾に以下を追加します。

model Post {
  slug     String @id
  title    String
  markdown String

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

続けて以下のコマンドを実行して、変更した内容をデータベースに反映します。これによって、新たにPostテーブルがデータベースに追加されます。

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

また、Postに対して初期データを追加するために prisma/seed.tsに以下を追加し、その後ターミナルにて npx prisma db seed を実行してデータを反映します。

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

// これの直前に追加する
console.log(`Database has been seeded. 🌱`);
npx prisma db seed

最後に、 app/models/post.server.js のgetPosts関数の中身をprisma介してデータベースからデータを取得する形に変更すれば完成です

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

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

今回はPrismaを使用したコードの解説は省略させていただくので、気になる方は公式サイトのドキュメントを確認してください。

Prisma Documentation | Concepts, Guides, and Reference

投稿の詳細ページを追加

次に、投稿の詳細ページを作成していきます。投稿詳細ページはURLに投稿のID(Postテーブルのslugカラム)の値を使用します(例: /posts/my-first-post … my-first-postが投稿のID)

このように、固定のURLではなく任意の値を設定できる様にするためにはRemixの動的セグメントという機能を使用します。

 

 

動的セグメントを使用するには、ファイル名に $ をつけます。なので、今回作成する投稿詳細ページのファイル名は posts.$slug.jsx とします。

touch app/routes/posts.\\$slug.jsx

例の如くファイルの中身は簡易的なものにして、ページがきちんと作成できているか確認しましょう。

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

エラーがないようであれば、詳細ページの中身を作っていきます。

app/routes/posts.$slug.jsx の中身を以下の様に編集します。

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const loader = async ({ params }) => {
  return json({ slug: params.slug });
};

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

動的セグメントに当たる部分に設定されている値はloader関数に渡される引数paramsから取得することができます。

例えば上の例でいうと、 $slug という動的セグメントが設定されているため params.slug という呼び出し方で値を取得しています。

この値を元にしてデータベースに対して検索をかけ、投稿のタイトルを画面に表示する様に変更したものが以下の通りです。

// app/models/post.server.js

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

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

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

export const loader = async ({ params }) => {
  const post = await getPost(params.slug);
  return json({ post });
};

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

当記事ではJavaScriptを使用しているためチュートリアルにある「TypeScriptの警告」は出ていませんが、動的セグメントの値が渡されなかった場合やデータベースにデータが存在しない場合を考慮して、 tiny-invaliant を使用した簡単な値チェックを追加しておきます。

// ...

import invariant from "tiny-invariant";

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

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

  return json({ post });
};

// ...

tiny-invaliant の invariant関数は、第一引数に渡した値がtrueとなる値か、falseとなる値かによって挙動をします。

値がtrueの場合は何もせず、falseの場合は第二引数に渡した値をエラーメッセージとした例外を発生させ、処理をそこで強制終了させます。

参考: https://www.npmjs.com/package/tiny-invariant

最後に、詳細ページに表示する内容を投稿のタイトルだけでなく、コンテンツ(markdownカラム)の値も表示させます。

マークダウンをHTMLに変換するために、チュートリアルではmarkedライブラリを使用しています。

ライブラリを追加したのちサーバーを再起動し、 app/routes/posts.$slug.jsx の内容をマージダウンを表示する形に変更して、投稿詳細ページの完成です

npm add marked
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 }) => {
  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({ html, post });
};

export default function PostSlug() {
  const { html, post } = useLoaderData();
  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>
  );
}

localhost:3000/posts/90s-mixtape にアクセスし、以下のように表示されていれば完璧です(TailwindCSSのリセットCSSが適用されているため、CSSで装飾はされていません)。

新規投稿ページを追加する

最後に、新規投稿ページを追加しながらネステッドルーティングインデックスルートaction関数によるバックエンド連携について学んでいきます。

まずは新たに管理画面ページを追加し、投稿一覧ページにそのページへのリンクを追加しましょう。

// app/routes/posts._index.jsx

// これの下に追加
<h1>Posts</h1>

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

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

特出すべき点は app/routes/posts._index.jsx に追加したLinkコンポーネントです。

to=”/posts/admin” ではなく to="admin" としか記載されていないにも関わらず、Adminをクリックすると localhost:3000/posts/admin へと遷移します。

これは、RemixのLInkコンポーネントが相対パスが使用できるからです。使用イメージとしてはLinuxコマンドのcdコマンドをイメージするとわかりやすいと思います。

RemixのLInkコンポーネントは、React Routerのものをラップしたものなので、詳細が知りたい方は、React Routerのドキュメントを確認してください。

 

 

続けて管理者ページに新規投稿用のフォームを追加していきましょう。新規投稿フォームは、Remixのネステッドルーティングという機能を使って子のルート(コンポーネント)として定義します。

ここでネステッドルーティングの使い方を中心に解説しています。

どのような概念かの説明は公式ドキュメントにて詳細に説明されているため、そちらもご確認ください。

参考: https://remix.run/docs/en/main/guides/routing

まずは管理者ページに対応するインデックスルートを追加します。インデックスルートを簡単に説明すると「子コンポーネントを表示するときのデフォルトの表示」のことで、ファイル名に ._index を含むものを指します。

touch app/routes/posts.admin._index.jsx
// app/routes/posts.admin._index.jsx

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というコンポートネントを使用します。 app/routes/posts.admin.jsx Outletを表示するように変更を加えます。

import { json } from "@remix-run/node";
// Outletを追加
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();
  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を表示する形に変更 */}
          <Outlet />
        </main>
      </div>
    </div>
  );
}

localhost:3000/posts/admin にアクセスして、<Outlet /> にインデックスルート( posts.admin._index.jsx )の内容が表示されていることを確認してください。

この<Outlet />に新規投稿用のフォームが表示できるようにして、管理画面のレイアウトは完成です。

touch app/routes/posts.admin.new.jsx
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>
  );
}

localhost:3000/posts/admin/new は以下のようになります。

新規投稿フォームから新しい投稿を保存する

現在の状態では、新規投稿フォームから情報を送信してもエラーになってしまいます。

この情報をデータベースなどに保存するためには、フォームに対応したバックエンドAPIを準備する必要があります。

Remixではこのようなフォームに対応したバックエンドAPIを用意するためにaction関数を定義することができます。action関数は、チュートリアルの前半に記載したloader関数と同様にroutes内のファイルに直接記載します。

app/models/post.server.js および app/routes/posts.admin.new.jsx に以下のコードを追加してください。

// app/models/post.server.js

// ..

// Postテーブルにデータを保存するための記述
export async function createPost(post) {
  return prisma.post.create({ data: post });
}
// app/routes/posts.admin.new.jsx

// ...

import { redirect } from "@remix-run/node";
import { createPost } from "~/models/post.server";

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("/posts/admin");
};

// ...

これだけで投稿がDBに登録する処理が完成します。

当記事ではTypeScriptを使用していないので、TypeScriptの警告に伴う修正は行なっておりません。

そのため、チュートリアル原文とソースコードが異なる部分がございます。

💡 action関数の中で使用されているrequestformDataはWeb標準のものなので、詳細な仕様を知りたい方はMDNを確認してください

参考:

バリデーションの追加

これにてブログチュートリアルの終了!

と、言いたいところですがもう少しだけ続きます。具体的には、ユーザーの入力に対するバリデーションとエラーメッセージの表示を追加します。

app/routes/posts.admin.new.jsx のaction関数にて、値が入力されていないものがあればエラーレスポンスを返すように修正しましょう。

// ...

// jsonを追加
import { json, redirect } from "@remix-run/node";

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

  // 値が空文字の場合、JSON形式でエラーメッセージを返す処理を追加
  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");
};

// ...

入力フォームに何も入力せずに送信ボタンを押すと、エラーメッセージを含んだレスポンスを返し、データの保存処理は行われません。

以下はGoogle Chromeの拡張機能で確認した通信の内容になります。エラーメッセージが帰ってきていることがわかりますね。

このエラーメッセージを取得し、画面に表示するためにはuseActionDataを使います。

// useActionDataを追加
import { Form, useActionData } from "@remix-run/react";

// ...

export default function NewPost() {
  // エラーメッセージのレスポンスを取得
  const errors = useActionData();

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

 

ということで、これにてチュートリアルのコーディング完了とします。お疲れ様でした!

課題について

Remixのチュートリアルの最後にはいくつかの課題が用意されていますが、今回はこちらについて解説はしておりません。

プログレッシブエンハンスメントについて

最後に、Remixの思想として重要なプログレッシブエンハンスメントにてついて触れておきます。

この思想は「どんな環境の人でも最低限機能するアプリを作ろうよ」というもので、例えばJavaScriptが使えない(あるいは無効化している)ユーザーが利用した場合でも最低限動作しつつ、全ての機能が利用できる様な環境にいる人にはより良いユーザー体験を提供するようなアプリを作るをする、といった設計思想です。

💡 Remixの思想やプログレッシブエンハンスメントについて詳しく知りたい方は、以下のページを確認してください。

参考:

 

体験してみる

先ほどはチュートリアルにおけるコーディングは完了と記載しましたが、せっかくなのでプログレッシブエンハンスメントを体験してみましょう。

app/routes/posts.admin.new.jsx に以下のコードを追加した上でブラウザのJavaScriptを無効化して、新規投稿を作成してみてください。

JavaScriptが有効でも無効でも正常に動作しつつ、ユーザーの環境によって提供する内容が変わっていることが確認できます。

// useNavigationを追加
import { Form, useActionData, useNavigation } from "@remix-run/react";

// ...

export const action = async ({ request }: ActionArgs) => {
  // 3秒待つ処理を追加
  await new Promise((res) => setTimeout(res, 3000));

// ...

export default function NewPost() {
  const errors = useActionData();

	// データの送信中かどうかのフラグを定義
  const navigation = useNavigation();
  const isCreating = Boolean(
    navigation.state === "submitting"
  );

return (
	<Form method="post">
	  {/* ... */} 

     	{/* JavaScriptが有効な場合は、送信ボタンの表記が Creating... となるように修正 */} 
			<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>
	);
}

まとめ

ここまでJavaScriptでRemixのチュートリアル(shot)を進めてきました。

チュートリアル(long)をやるとわかりますが、ここで紹介された機能はほんの一部でしかなく、Remixにはまだまだ便利な機能があるので、ぜひ一度そちらも進めてみてください!

 

 

また、今回はTypeScriptに慣れていないという理由でJavaScriptで進めてきましたが、正直なところどちらも書き方は似ているため、「型によるミスを減らすという意味」でもTypeScriptを勉強して採用するという選択はより良いものだと感じました

ブログに戻る

コメントを残す

コメントは公開前に承認される必要があることにご注意ください。