RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

次世代フレームワークRemixで簡単なフルスタック開発を体験する

はじめに

こんにちは。フロントエンド開発課に所属している新卒1年目のm_you_sanと申します。
最近話題のRemixを使って、シンプルなTodoアプリを作成する方法をご紹介します。
Todoアプリの作成を通じて、簡単なフルスタック開発を体験していただければと思います。

プロジェクトの作成

はじめに以下のコマンドを実行して、プロジェクトを作成します。
※Node.js v18以上、npm v7以上がインストールされていることが前提です。

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

今回はindie-stackというテンプレートを使用しています。
このテンプレートを使用することで、プロジェクトにSQLitePrisma、tailwind cssなどが予めインストールされた状態になります。
※今回はデザインはこだわらないので、tailwind cssの設定は削除しています。
さらに詳しくテンプレート機能について知りたい方は、公式のドキュメントを読んでみてください。

npm run devを実行し、以下のような画面が表示されれば、プロジェクトの作成は完了です。

モデルの定義

次にprismaにモデルの定義をしていきます。
schema.prismaを開いて、最終行に以下のコードを追加してください。

model Todo {
  id    String @id @default(cuid())
  title String
  deadline String
  isDone Boolean @default(false)

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

保存した後、以下のコマンドでマイグレーションを実行します。

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


次に/modelsの直下にtodo.server.tsを作成します。
todo.server.tsでは、バックエンドのデータを操作するための関数をまとめています。

// todo.server.ts
export const getTodos = () => {
  return prisma.todo.findMany();
};

export const getTodo = (id: string) => {
  return prisma.todo.findUnique({ where: { id } });
};

export const createTodo = (todo: Pick<Todo, "title" | "deadline">) => {
  return prisma.todo.create({ data: todo });
};

export const updateTodo = (
  id: string,
  todo: Pick<Todo, "title" | "deadline" | "isDone">,
) => {
  return prisma.todo.update({
    where: { id },
    data: todo,
  });
};

export const deleteTodo = (id: string) => {
  return prisma.todo.delete({ where: { id } });
};

Root Routeについて

画面を作成する前に、Root Routeについて簡単に解説したいと思います。
Root Routeは、/appにあるroot.tsxのことを指しています。root.tsxは、アプリケーション全体の共通設定やヘッダー、フッターといった共通のレイアウトを定義することができます。

また、root.tsxはアプリケーションのトップレベルで定義され、他のページの親コンポーネントとなっています。
root.tsxの子コンポーネント<Outlet />内でレンダリングされます。

// root.tsx
export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {/* ここで子がレンダリングされます */}
        <Outlet />
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

ルーティングについて

Remixでは、ファイルシステムベースのルーティングになっており、app/routes配下に置いたファイルがそのままアプリケーションのURLとなります。
また、動的セグメントを利用したい場合は、$をファイル名に付けます。

ファイル名 URL
_index.tsx /
todos._index.tsx /todos
todos.create.tsx /todos/create
todos.$todoId.edit.tsx /todos/{todoId}/edit

一覧画面の作成

Todo一覧画面を作成していきます。
まず、トップページの方を修正していきます。

// _index.tsx
export default function Index() {
  return (
    <main>
      <h2>トップページ</h2>
      <Link to="todos">Todo一覧画面へ</Link>
    </main>
  );
}

次に/routes直下にtodos._index.tsxを作成します。

// todos._index.tsx
export const loader = async () => {
  return json({ todos: await getTodos() });
};

const Todos: FC = () => {
  const { todos } = useLoaderData<typeof loader>();

  return (
    <>
      <h2>Todo一覧画面</h2>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <p>タイトル:{todo.title}</p>
            <p>期限:{todo.deadline}</p>
            <p>進捗:{todo.isDone ? "完了" : "未着手"}</p>
          </li>
        ))}
      </ul>
    </>
  );
};

export default Todos;

loader関数は、データ取得する際に使用するバックエンドAPIの役割をしています。 この関数はサーバサイドで呼ばれ、先程todo.server.tsで定義したgetTodosで取得したデータを返却します。
コンポーネント内で定義されているuseLoaderDataは、loader関数で返却されるデータを取得するためのhooksです。 今回はuseLoaderDataでtodoを全件取得し、map関数で展開して、一覧表示させています。
この時点では、todoが登録されていないため、何も表示されません。

新規追加画面の作成

/routes直下にtodos.create.tsxを作成します。

// todos.create.tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();

  const title = formData.get("title");
  const deadline = formData.get("deadline");

  const errors = {
    title: title ? null : "タイトルは必須です",
    deadline: deadline ? null : "期限は必須です",
  };

  const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);

  if (hasErrors) return json(errors);

  invariant(typeof title === "string");
  invariant(typeof deadline === "string");

  await createTodo({ title, deadline });

  return redirect("/todos");
};

const CreateTodo: FC = () => {
  const errors = useActionData<typeof action>();
  return (
    <>
      <h2>新規追加画面</h2>
      <Form method="post">
        <div>
          <label>
            タイトル:
            {errors?.title ? (
              <p style={{ color: "red" }}>{errors.title}</p>
            ) : null}
            <input type="text" name="title" />
          </label>
        </div>
        <div>
          <label>
            期限:
            {errors?.deadline ? (
              <p style={{ color: "red" }}>{errors.deadline}</p>
            ) : null}
            <input type="date" name="deadline" />
          </label>
        </div>
        <div>
          <button type="submit">登録</button>
        </div>
      </Form>
    </>
  );
};

export default CreateTodo;

一覧画面と異なりloader関数ではなく、action関数を使用しています。loader関数がデータの取得に対して、action関数は、データの更新やサーバーへのリクエストなどを実行するバックエンドのAPIの役割をしています。
loader関数と同じくサーバサイドで呼ばれます。

action関数内では、まずformData.getでフォームから送られてきたtitledeadlineを取得しています。
action関数内で使用しているformDatarequestWeb標準APIです。詳細はMDNを参照してみてください。
titledeadlineが未入力だった場合は、エラーメッセージを返却します。
invariantは引数の内の条件式がfalseだった場合、例外を投げて処理を中断します。今回は、titledeadlineの型情報を絞り込むために使用しています。
登録完了後は、一覧画面にリダイレクトするようにしています。

コンポーネント側では、useActionDataaction関数から返却されるデータを受け取っています。エラーがあった場合は、エラーメッセージをそれぞれ、ラベルの下に表示するようにしています。

次に一覧画面から新規追加画面に遷移できるようにします。
RemixのLinkコンポーネントは、React RouterのLinkコンポーネントをラップしたものなので、相対パスを適用できます。そのため、to="/todos/create"ではなく、to="create"と記載するだけで新規追加画面へ遷移することが可能です。

// todos._index.tsx
・・・中略・・・
<h2>Todo一覧画面</h2>
    <Link to="create">新規追加画面へ</Link>
・・・中略・・・

最後に動作を確認してみます。
登録完了後、一覧画面に遷移されて登録したtodoが表示されていると思います。

新規追加画面
登録完了後の一覧画面

編集画面の作成

/routes直下にtodos.$todoId.edit.tsxを作成します。

// todos.$todoId.edit.tsx
export const loader = async ({ params }: LoaderFunctionArgs) => {
  invariant(params.todoId);
  const todo = await getTodo(params.todoId);
  invariant(todo);
  return json({ todo });
};

export const action = async ({ params, request }: ActionFunctionArgs) => {
  const formData = await request.formData();

  const title = formData.get("title");
  const deadline = formData.get("deadline");
  const isDone = formData.get("isDone") === "done";

  const errors = {
    title: title ? null : "タイトルは必須です",
    deadline: deadline ? null : "期限は必須です",
  };

  const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);

  if (hasErrors) return json(errors);

  invariant(params.todoId);
  invariant(typeof title === "string");
  invariant(typeof deadline === "string");

  await updateTodo(params.todoId, { title, deadline, isDone });

  return redirect("/todos");
};

const EditTodo: FC = () => {
  const { todo } = useLoaderData<typeof loader>();
  const errors = useActionData<typeof action>();

  return (
    <>
      <h2>編集画面</h2>
      <Form method="POST">
        <div>
          <label>
            タイトル:
            {errors?.title ? (
              <p style={{ color: "red" }}>{errors.title}</p>
            ) : null}
            <input type="text" name="title" defaultValue={todo.title} />
          </label>
        </div>
        <div>
          <label>
            期限:
            {errors?.deadline ? (
              <p style={{ color: "red" }}>{errors.deadline}</p>
            ) : null}
            <input type="date" name="deadline" defaultValue={todo.deadline} />
          </label>
        </div>
        <div>
          <input
            id="done"
            type="radio"
            name="isDone"
            value="done"
            defaultChecked={todo.isDone}
          />
          <label htmlFor="done">完了</label>
          <input
            id="notDone"
            type="radio"
            name="isDone"
            value="notDone"
            defaultChecked={!todo.isDone}
          />
          <label htmlFor="notDone">未着手</label>
        </div>
        <div>
          <button type="submit">更新</button>
        </div>
      </Form>
    </>
  );
};

export default EditTodo;

編集画面では、loader関数を使って動的セグメントで指定したtodoIdを取得して、それに対応するtodoを取得しています。 更新リクエストにも、todoIdが必要なので、action関数の方でもtodoIdを取得しています。その他の処理は、新規追加画面のaction関数とほぼ同じです。
useLoaderDataでデータを取得する流れは一覧画面と同様で、フォームのデフォルト値に取得したデータを設定しています。

編集画面は完成したので、一覧画面から遷移できるようにします。

// todos._index.tsx
・・・中略・・・
<li key={todo.id}>
   {/* タイトルから編集画面に遷移できるようにする */}
    <Link to={`${todo.id}/edit`}>タイトル:{todo.title}</Link>
・・・中略・・・

最後に動作を確認してみます。

一覧画面(todo更新前)
編集画面
一覧画面(todo更新後)

削除機能の追加

最後に削除機能を追加します。 /routes直下にtodos.$todoId.delete.tsを作成します。

// todos.$todoId.delete.ts
export const action = async ({ params }: ActionFunctionArgs) => {
  invariant(params.todoId);
  await deleteTodo(params.todoId);
  return redirect("/todos");
};

action関数内では、動的セグメントで指定したtodoIdを取得して、それに対応するtodoを削除しています。削除完了後は、一覧画面にリダイレクトします。

一覧画面からtodoを削除できるようにしたいので、一覧画面に削除ボタンを追加して、押下時に/todos/{todoId}/deleteにリクエストを送るようにします。

// todos._index.tsx
・・・中略・・・
<p>進捗:{todo.isDone ? "完了" : "未着手"}</p>
<Form action={`${todo.id}/delete`} method="post">
     <button type="submit">削除</button>
</Form>
・・・中略・・・

削除ボタンをFormコンポーネントで囲って、actionにパスを指定しています。これにより、ボタン押下時にtodos.$todoId.delete.tsaction関数が呼び出されます。
なお、Formコンポーネントactionを省略した場合は、同ファイル内のaction関数が呼ばれます。

動作を確認して、削除ボタン押下時に一覧画面からtodoが削除されていれば、実装完了です。

まとめ

今回はRemixで簡単なTodoアプリを作成する方法について、紹介させていただきました。
Todoアプリの作成を通じて、フロントエンドとサーバーサイドのコードを同時に扱う体験をしていただけたと思います。
また、この記事がRemixをこれから学ぼうとする方々の理解を深める助けになれば幸いです。
私自身もまだRemix初学者なので、引き続き知識を深めていきたいと考えています!

Copyright © RAKUS Co., Ltd. All rights reserved.