はじめに
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 を開きます
環境構築ができたのでTailwindCSSを導入していきます。
$ npm i tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
tailwind.config.js
を開いて以下に置き換えます
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
app/tailiwnd.css
ファイルを作成して以下をいれます
@tailwind base;
@tailwind components;
@tailwind utilities;
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を変更します
plugins: [require("daisyui")],
ここまでいけば導入が完了したので実際にCSSが適応されるかを確認します。
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
2. supabaseの導入 (TODOの一覧表示)
supabaseのアカウント登録とプロジェクト作成を各自行ってください
ここでは説明を省略しています (簡単です)
Table Editorから以下のテーブルを作成します。
テーブル名 : todo
カラム名 | タイプ | Null許容 | デフォルト |
---|---|---|---|
title | varchar | ☓ | |
done | bool | ☓ | FALSE |
ここで注意はRLSを無効化しておくことです。ここが無効化していないとデータ取得のときにデータがうまく返ってこなくなります。
テーブルを作成したらテストデータを3つほど適当に入れておいてください
では、ここから実際にsupabaseクライアントを作成してデータ取得をしたいと思います。
lib
というディレクトリを作成して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
というファイルをディレクトリ直下に作成して以下をいれます
SUPABASE_URL=あなたのURL
SUPABASE_ANON_KEY=あなたの秘密鍵
supabaseのconfigからAPIを選択してそれぞれの情報を取得しておきます。
では実際にデータ取得をするコードを書いていきます。
_index.tsx
にデータ取得をするコードを追加します。
export default function Index() {
const { todos } = useLoaderData() as { todos: Todo[] };
アプリがtodosを初期化するタイミングでuseLoaderData()
というのが呼び出されます。これを利用することでRemixではLoaderというものを実行することが可能です
ローダーを作成して、Todoデータを取得して返すように実装します
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
を作成します
export class Todo {
constructor(public id: string, public title: string, public done: boolean) {}
}
このようにすることで画面を読み込んだタイミングでローダーが実行されてTODOが返ってくるようになるので実際に表示をしていきます。
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が表示されました
3. 新規登録を実装する
新規登録ボタンやフォームなどを実装していきます。
ここは本質でないため大切な部分のみを紹介します
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>
);
}
モーダルはDaisyUIを利用して作成しました。
RemixのFormを利用してフォームの実装を行っていきます。
Formではmethod=post
を設定しています。Remixではpostがsubmitされるとaction
というものがサーバーで実行されます。その後loader
が実行されてステートがサーバーから送られてきてそれをクライアント側で表示して更新していきます。
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にデータ追加が行われて、その後のローダーで新たな値が反映されるようになります。
ここで追加後にモーダルが閉じなかったり、インプットを空にできてしまうのでバリデーションを実装します。
先程タイトルがないときに以下のようにエラーをjsonで返すようにしていたのでこのエラーをクライアント側で識別していきます
if (!title) {
return json({ error: "タイトルを入力してください" }, { status: 400 });
}
ここでuseActionData
を使ってエラーが送られてきた場合にその内容をフォームに表示することができます
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>
);
}
4. 削除の実装
削除を実装しますが、削除もFormを利用するのでaction
を実行することになります
ここでactionの実行が新規作成なのか削除なのかを見分ける必要が有ります。
ここで利用するのがしれっとかいていたこの項目です
<button
className="btn btn-primary mr-4"
type="submit"
name="action"
value="create"
>
ボタンに対してアクションの内容をバリューで持たせるようにしているので、ここを判断して分岐することで実装が可能です
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 });
}
ここで条件分岐を行って削除を行うようにしました
これでハンズオンは終了です!
おわりに
いかがでしたでしょうか。
ステートを利用しなくてよくなることでクライアントとサーバーを意識することなく実装ができました。その反面actionが複雑になってしまうようにも感じたのでここはもう少しプラクティスを探したいなと感じました。
ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@Sicut_study をフォローいただけるととてもうれしく思います。
また明日の記事でお会いしましょう!
JISOUのメンバー募集中
プログラミングコーチングJISOUではメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
気になる方はぜひHPからお願いします👇