【ハンズオン】RemixでTODOアプリを作ってReactの違いを体感しよう【TypeScript/Supabase/TailwindCSS】

# はじめに

**Reactを使っていてステートがクライアントとサーバーで辻褄が合わなくなった**

そんな経験がReactをある程度使ったことがある人はおそらく経験したことがあるはずです。
Reactにおいて状態管理は誰でも使いやすく直感的である半面、クライアントとサーバーの状態を意識する必要が有ります。
どのタイミングでステートの変更をサーバーでも行うのか難しく思う場面もしばしばあります。

今回は最近巷でReactと並んで見かけるようになったRemixについてハンズオン形式で学べるような記事を書いていきます。

ハンズオンを通してRemixの特徴であったり、SupabaseやTailwindCSSなど個人開発でよく利用されるモダンな技術についても学ぶことが可能です。

# Remixが利用され始めている実例

https://user-first.ikyu.co.jp/entry/2023/12/15/093427

https://tech.mfkessai.co.jp/2024/03/remix/

一休やマネーフォワードなどではRemixを使い始めたり移行したりするなど、2024年はRemixの人気が高まってくると予想されています。

# RemixとReactの違い

実際にハンズオンで違いについては学びますが、特徴は「ステート管理をあまりしない」「Web標準で実装する」などが大きく上げられます。

ステート管理をクライアントですることがないことで、**クライアントとサーバーでの状態を意識する必要がなく**、サーバーで常に状態管理をしてクライアントではそのデータを表示することだけに集中します。

またWeb標準で実装することも大きなメリットで**Web標準のAPIであれば仕様そのものが変わることが少ないため学んだ技術が使えなくなってしまう(古くなってしまう)ということが避けられる**ようになります。Next.jsで13になって勉強しなおさないとのようなことも減ってくることから大きなプロダクトには向いているのかも知れません。

https://zenn.dev/acompany/articles/123c29f46d213c

# RemixでTODOアプリを作成する

今回はRemix/TypeScript/Supabase/TailwindCSS(DaisyUI)を利用してTODOアプリを作りながらRemixの基本的な使い方について学んでいきます。

初心者の方やSupabseを触ったことがない方は動画でも解説していますのでご利用ください👇

<iframe width="560" height="315" src="https://www.youtube.com/embed/KdmGn_1Pru4?si=uQ6fUKpXpvSHGjRK" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

## 1. 環境構築

まずはRemixの環境を作成してTailwindCSSとDaisyUIを導入します。

```
$ npm create vite@latest
? Project name: › remix-todo
✔ Select a framework: › React
✔ Select a variant: › Remix ↗
Install a new git repository? -> Yes
Install depencies with npm? -> Yes

$ cd remix-todo
$ npm run dev
```

http://localhost:5123 を開きます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/56e16871-6ec7-93c3-7263-d49b26e0ff3b.png)

環境構築ができたのでTailwindCSSを導入していきます。

```
$ npm i tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
```

`tailwind.config.js`を開いて以下に置き換えます

```js:tailwind.config.js
module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
```

`app/tailiwnd.css`ファイルを作成して以下をいれます

```css:app/tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

`app/root.tsx`に以下を上に追加します

```tsx:app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

import "./tailwind.css"; // 追加
```

次にDaisyUIを導入します。

```
$ npm i -D daisyui@latest
```

`tailwind.config.js`のpluginsを変更します

```js:tailiwnd.config.js
  plugins: [require("daisyui")],
```

ここまでいけば導入が完了したので実際にCSSが適応されるかを確認します。
`app/routes/_index.tsx`をいかに置き換えます

```tsx:app/routes/_index.tsx
import type { MetaFunction } from "@remix-run/node";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

export default function Index() {
  return (
    <div>
      <button className="btn">Button</button>
    </div>
  );
}
```

サーバーを再起動して画面をひらいてボタンが表示されていれば環境構築完了です

```
$ npm run dev
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/29b35855-10b6-ad18-93f0-8214eb9f5a37.png)

## 2. supabaseの導入 (TODOの一覧表示)

https://supabase.com/

supabaseのアカウント登録とプロジェクト作成を各自行ってください
ここでは説明を省略しています (簡単です)

Table Editorから以下のテーブルを作成します。

テーブル名 : todo

|カラム名|タイプ|Null許容|デフォルト|
|---|---|---|---|
|title|varchar|☓||
|done|bool|☓|FALSE|

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/5a0cc57e-1f8c-a684-def3-e2327d5b5c97.png)

ここで注意はRLSを無効化しておくことです。ここが無効化していないとデータ取得のときにデータがうまく返ってこなくなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/60af2c02-124c-c131-3a6e-a0c0c85a5b64.png)

テーブルを作成したらテストデータを3つほど適当に入れておいてください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/5bcd9ff1-c1c0-d0f0-702b-4c527d28397d.png)

では、ここから実際にsupabaseクライアントを作成してデータ取得をしたいと思います。

`lib`というディレクトリを作成して`lib/supabase.ts`を作成します

```ts:lib/supabase.ts
import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!
);
```

ここでsupabase-clientというライブラリを利用してクライアントの初期化をする設定をしました。ライブラリもインストールしておきます

```
$ npm install @supabase/supabase-js
```

次にsupabaseの環境変数を設定していきます。
`.env`というファイルをディレクトリ直下に作成して以下をいれます

```.env:.env
SUPABASE_URL=あなたのURL
SUPABASE_ANON_KEY=あなたの秘密鍵
```

supabaseのconfigからAPIを選択してそれぞれの情報を取得しておきます。

では実際にデータ取得をするコードを書いていきます。
`_index.tsx`にデータ取得をするコードを追加します。

```tsx:_index.tsx
export default function Index() {
  const { todos } = useLoaderData() as { todos: Todo[] };
```

アプリがtodosを初期化するタイミングで`useLoaderData()`というのが呼び出されます。これを利用することでRemixではLoaderというものを実行することが可能です
ローダーを作成して、Todoデータを取得して返すように実装します

```tsx:_index.tsx

export const loader = async () => {
  const response = await supabase.from("todo").select("*");
  const data = response.data;

  if (!data) {
    return json({ todos: [] });
  }

  const todos = data.map((todo) => new Todo(todo.id, todo.title, todo.done));

  return json({ todos });
};
```

ここでTODOというドメインを作成使っているのでこれも作成します
`domain`ディレクトリを作成して`domain/Todo.ts`を作成します

```ts:domain/Todo.ts
export class Todo {
  constructor(public id: string, public title: string, public done: boolean) {}
}
```

このようにすることで画面を読み込んだタイミングでローダーが実行されてTODOが返ってくるようになるので実際に表示をしていきます。

```tsx:app/routes/_index.tsx
import type { MetaFunction } from "@remix-run/node";
import { json, useLoaderData } from "@remix-run/react";
import { Todo } from "~/domain/Todo";
import { supabase } from "~/lib/supabase";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

export const loader = async () => {
  const response = await supabase.from("todo").select("*");
  const data = response.data;

  if (!data) {
    return json({ todos: [] });
  }

  const todos = data.map((todo) => new Todo(todo.id, todo.title, todo.done));

  return json({ todos });
};

export default function Index() {
  const { todos } = useLoaderData() as { todos: Todo[] };
  return (
    <div className="font-sans m-80">
      <h1 className="text-2xl font-bold mb-8">やることリスト</h1>
      {todos.map((todo) => (
        <div
          key={todo.id}
          className="flex items-center border-b border-gray-300 py-2"
        >
          <div className="flex-grow">{todo.title}</div>
        </div>
      ))}
    </div>
  );
}
```

.envを読み込むためにサーバーを再起動してからひらくとTODOが表示されました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/e80069be-2b46-6b37-032d-bfc8b0be7b53.png)

## 3. 新規登録を実装する

新規登録ボタンやフォームなどを実装していきます。
ここは本質でないため大切な部分のみを紹介します

```tsx:app/routes/_index.tsx
import type { MetaFunction } from "@remix-run/node";
import { Form, json, useLoaderData } from "@remix-run/react";
import { Todo } from "~/domain/Todo";
import { supabase } from "~/lib/supabase";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

export const loader = async () => {
  const response = await supabase.from("todo").select("*");
  const data = response.data;

  if (!data) {
    return json({ todos: [] });
  }

  const todos = data.map((todo) => new Todo(todo.id, todo.title, todo.done));

  return json({ todos });
};

export default function Index() {
  const { todos } = useLoaderData() as { todos: Todo[] };

  const closeModal = () => {
    (document.getElementById("my_modal_1") as HTMLDialogElement).close();
  };

  return (
    <div className="font-sans m-80">
      <h1 className="text-2xl font-bold mb-8">やることリスト</h1>
      {todos.map((todo) => (
        <div
          key={todo.id}
          className="flex items-center border-b border-gray-300 py-2"
        >
          <div className="flex-grow">{todo.title}</div>
        </div>
      ))}
      <div className="mt-8 flex justify-end">
        <button
          className="btn btn-success"
          onClick={() => {
            (
              document.getElementById("my_modal_1") as HTMLDialogElement
            ).showModal();
          }}
        >
          新規登録
        </button>
      </div>

      <dialog id="my_modal_1" className="modal">
        <div className="modal-box">
          <h3 className="font-bold text-lg">新規登録</h3>
          <div className="label-text mb-4">やること</div>
          <Form method="post">
            <input
              type="text"
              className="input input-bordered w-full mb-4"
              name="title"
              id="title"
            />
            <div className="flex">
              <button
                className="btn btn-primary mr-4"
                type="submit"
                name="action"
                value="create"
              >
                登録
              </button>
              <button className="btn" type="button" onClick={closeModal}>
                閉じる
              </button>
            </div>
          </Form>
        </div>
      </dialog>
    </div>
  );
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/dbcf9423-fe9c-8d80-2383-d8dfb74a8ac8.png)

モーダルはDaisyUIを利用して作成しました。
RemixのFormを利用してフォームの実装を行っていきます。

Formでは`method=post`を設定しています。Remixではpostがsubmitされると`action`というものがサーバーで実行されます。その後`loader`が実行されてステートがサーバーから送られてきてそれをクライアント側で表示して更新していきます。

```ts:app/routes/_index.tsx

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const title = formData.get("title") as string;
  if (!title) {
    return json({ error: "タイトルを入力してください" }, { status: 400 });
  }
  await supabase.from("todo").insert({ title, done: false });
};
```

このようにすることでsubmitが起きたときにactionでsupabaseにデータ追加が行われて、その後のローダーで新たな値が反映されるようになります。

![Peek 2024-05-05 15-57.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/c62a48eb-c9c4-8208-51ea-9f3310f21665.gif)

ここで追加後にモーダルが閉じなかったり、インプットを空にできてしまうのでバリデーションを実装します。

先程タイトルがないときに以下のようにエラーをjsonで返すようにしていたのでこのエラーをクライアント側で識別していきます

```ts
  if (!title) {
    return json({ error: "タイトルを入力してください" }, { status: 400 });
  }
```

ここで`useActionData`を使ってエラーが送られてきた場合にその内容をフォームに表示することができます

```tsx:app/routes/_index.tsx
import type { ActionFunctionArgs, MetaFunction } from "@remix-run/node";
import { Form, json, useActionData, useLoaderData } from "@remix-run/react";
import { useEffect } from "react";
import { Todo } from "~/domain/Todo";
import { supabase } from "~/lib/supabase";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

export const loader = async () => {
  const response = await supabase.from("todo").select("*");
  const data = response.data;

  if (!data) {
    return json({ todos: [] });
  }

  const todos = data.map((todo) => new Todo(todo.id, todo.title, todo.done));

  return json({ todos });
};

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const title = formData.get("title") as string;
  if (!title) {
    return json({ error: "タイトルを入力してください" }, { status: 400 });
  }
  await supabase.from("todo").insert({ title, done: false });

  return null;
};

export default function Index() {
  const { todos } = useLoaderData() as { todos: Todo[] };
  const actionData = useActionData<{ error: string }>();

  useEffect(() => {
    if (!actionData?.error) {
      closeModal();
      (document.getElementById("title") as HTMLInputElement).value = "";
    }
  }, [actionData]); // エラーがなければフォームの初期化とモーダルを閉じる

  const closeModal = () => {
    (document.getElementById("my_modal_1") as HTMLDialogElement).close();
  };

  return (
    <div className="font-sans m-80">
      <h1 className="text-2xl font-bold mb-8">やることリスト</h1>
      {todos.map((todo) => (
        <div
          key={todo.id}
          className="flex items-center border-b border-gray-300 py-2"
        >
          <div className="flex-grow">{todo.title}</div>
        </div>
      ))}
      <div className="mt-8 flex justify-end">
        <button
          className="btn btn-success"
          onClick={() => {
            (
              document.getElementById("my_modal_1") as HTMLDialogElement
            ).showModal();
          }}
        >
          新規登録
        </button>
      </div>

      <dialog id="my_modal_1" className="modal">
        <div className="modal-box">
          <h3 className="font-bold text-lg">新規登録</h3>
          <div className="label-text mb-4">やること</div>
          <Form method="post">
            {actionData?.error && (
              <div className="text-red-500">{actionData.error}</div>
            )} // エラーの表示
            <input
              type="text"
              className="input input-bordered w-full mb-4"
              name="title"
              id="title"
            />
            <div className="flex">
              <button
                className="btn btn-primary mr-4"
                type="submit"
                name="action"
                value="create"
              >
                登録
              </button>
              <button className="btn" type="button" onClick={closeModal}>
                閉じる
              </button>
            </div>
          </Form>
        </div>
      </dialog>
    </div>
  );
}
```

![Peek 2024-05-05 16-02.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/871c50f8-7604-7dbe-bf0c-8bcbe4dc0e0c.gif)

## 4. 削除の実装

削除を実装しますが、削除もFormを利用するので`action`を実行することになります
ここでactionの実行が新規作成なのか削除なのかを見分ける必要が有ります。

ここで利用するのがしれっとかいていたこの項目です

```ts
              <button
                className="btn btn-primary mr-4"
                type="submit"
                name="action"
                value="create"
              >
```

ボタンに対してアクションの内容をバリューで持たせるようにしているので、ここを判断して分岐することで実装が可能です

```tsx:app/routes/_index.tsx
import { ActionFunctionArgs, json, type MetaFunction } from "@remix-run/node";
import { Form, useActionData, useLoaderData } from "@remix-run/react";
import { act, useEffect } from "react";
import { Todo } from "~/domain/Todo";
import { supabase } from "~/lib/supabase";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

export const loader = async () => {
  const response = await supabase.from("todo").select("*");
  const data = response.data;

  if (!data) {
    return json({ todos: [] });
  }

  const todos = data.map((todo) => new Todo(todo.id, todo.title, todo.done));

  return json({ todos });
};

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

  if (formData.has("action")) {
    const action = formData.get("action");

    if (action == "delete") {
      const id = formData.get("id");
      await supabase.from("todo").delete().eq("id", id);
    }

    if (action == "create") {
      const title = formData.get("title") as string;
      if (!title) {
        return json({ error: "タイトルを入力してください" }, { status: 400 });
      }
      await supabase.from("todo").insert({ title, done: false });
    }
  }

  return null
};

export default function Index() {
  const { todos } = useLoaderData() as { todos: Todo[] };
  const actionData = useActionData<{ error: string }>();

  useEffect(() => {
    if (!actionData?.error) {
      closeModal();
      (document.getElementById("title") as HTMLInputElement).value = "";
    }
  }, [actionData]);

  const closeModal = () => {
    (document.getElementById("my_modal_1") as HTMLDialogElement).close();
  };

  return (
    <div className="font-sans m-80">
      <h1 className="text-2xl font-bold mb-8">やることリスト</h1>
      {todos.map((todo) => (
        <div
          key={todo.id}
          className="flex items-center border-b border-gray-300 py-2"
        >
          <div className="flex-grow">{todo.title}</div>
          <Form method="post">
            <input type="hidden" name="id" value={todo.id} />
            <button
              name="action"
              value="delete"
              className="text-red-500"
              type="submit"
            >
              ☓
            </button>
          </Form>
        </div>
      ))}
      <div className="mt-8 flex justify-end">
        <button
          className="btn btn-success"
          onClick={() => {
            (
              document.getElementById("my_modal_1") as HTMLDialogElement
            ).showModal();
          }}
        >
          新規登録
        </button>
      </div>

      <dialog id="my_modal_1" className="modal">
        <div className="modal-box">
          <h3 className="font-bold text-lg">新規登録</h3>
          <div className="label-text mb-4">やること</div>
          <Form method="post">
            {actionData?.error && (
              <div className="text-red-500">{actionData.error}</div>
            )}
            <input
              type="text"
              className="input input-bordered w-full mb-4"
              name="title"
              id="title"
            />
            <div className="flex">
              <button
                className="btn btn-primary mr-4"
                type="submit"
                name="action"
                value="create"
              >
                登録
              </button>
              <button className="btn" type="button" onClick={closeModal}>
                閉じる
              </button>
            </div>
          </Form>
        </div>
      </dialog>
    </div>
  );
}
```


```tsx
    if (action == "delete") {
      const id = formData.get("id");
      await supabase.from("todo").delete().eq("id", id);
    }

    if (action == "create") {
      const title = formData.get("title") as string;
      if (!title) {
        return json({ error: "タイトルを入力してください" }, { status: 400 });
      }
      await supabase.from("todo").insert({ title, done: false });
    }
```

ここで条件分岐を行って削除を行うようにしました

![Peek 2024-05-05 16-06.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/f15d7f5f-4411-c568-fe7f-0412a20c3f06.gif)

これでハンズオンは終了です！

# おわりに

いかがでしたでしょうか。
ステートを利用しなくてよくなることでクライアントとサーバーを意識することなく実装ができました。その反面actionが複雑になってしまうようにも感じたのでここはもう少しプラクティスを探したいなと感じました。

ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@Sicut_study をフォローいただけるととてもうれしく思います。

また明日の記事でお会いしましょう！

# JISOUのメンバー募集中
プログラミングコーチングJISOUではメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか？

気になる方はぜひHPからお願いします👇

https://projisou.jp