107
101

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でTODOアプリを作ってReactの違いを体感しよう【TypeScript/Supabase/TailwindCSS】

Last updated at Posted at 2024-05-05

はじめに

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

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

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

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

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

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

RemixとReactの違い

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

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

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

RemixでTODOアプリを作成する

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

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

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

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

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

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

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

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

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

app/root.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を変更します

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

ここまでいけば導入が完了したので実際にCSSが適応されるかを確認します。
app/routes/_index.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

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

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

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

テーブル名 : todo

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

image.png

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

image.png

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

image.png

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

libというディレクトリを作成してlib/supabase.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
SUPABASE_URL=あなたのURL
SUPABASE_ANON_KEY=あなたの秘密鍵

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

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

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

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

_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を作成します

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

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

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

3. 新規登録を実装する

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

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

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

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

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

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

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

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

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

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

4. 削除の実装

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

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

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

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

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

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

おわりに

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

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

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

JISOUのメンバー募集中

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

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

107
101
2

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?