😉

Remix + Clerk + SupabaseでTodoアプリを作る

2024/04/17に公開

app\routes\page.tsx

import { json, LoaderFunction, ActionFunction,LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData, Form, redirect } from '@remix-run/react';
import { useEffect, useRef } from 'react';
import { getAuth } from "@clerk/remix/ssr.server";
import { createClient } from '@supabase/supabase-js';

// todo の型を定義
interface Todo {
  id: number;
  title: string;
}

// Supabase クライアントを生成するユーティリティ関数
const getSupabaseClient = async (args: LoaderFunctionArgs) => {
  const { getToken } = await getAuth(args);
  const token = await getToken({ template: 'supabase' });
  if (!token) {
    throw new Response("Token is missing", { status: 401 });
  }
  return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_KEY!, {
    global: { headers: { Authorization: `Bearer ${token}` } },
  });
};

// Todo アイテムを取得するためのローダー関数
export const loader: LoaderFunction = async (args) => {
  const supabase = await getSupabaseClient(args);
  const { data, error } = await supabase.from('todos').select('*');
  if (error) throw new Response("Failed to fetch todos", { status: 500 });
  return json(data);
};

// CRUD 操作を処理するアクション関数
export const action: ActionFunction = async (args) => {
  const supabase = await getSupabaseClient(args);
  const formData = await args.request.formData();
  const method = formData.get("_method") as string;
  const title = formData.get("title");

  const { userId } = await getAuth(args);

  switch (method) {
    case "create":
      
      if (typeof title === "string" && title.length > 0) {
        await supabase.from('todos').insert({ title, user_id: userId });
        return redirect("/page");
      }
      break;
    case "update":
      await supabase.from('todos').update({ title: formData.get("title") as string }).match({ id: parseInt(formData.get("id") as string) });
      break;
    case "delete":
      await supabase.from('todos').delete().match({ id: parseInt(formData.get("id") as string) });
      break;
  }
  return redirect("/page");
};

// Todo アイテムを表示するためのコンポーネント
function TodoRow({ todo }: { todo: Todo }) {
  return (
    <tr>
      <td>{todo.title}</td>
      <td>
        <Form method="post">
          <input type="hidden" name="_method" value="update" />
          <input type="hidden" name="id" value={todo.id.toString()} />
          <input type="text" name="title" defaultValue={todo.title} />
          <button type="submit">更新</button>
        </Form>
      </td>
      <td>
        <Form method="post">
          <input type="hidden" name="_method" value="delete" />
          <input type="hidden" name="id" value={todo.id.toString()} />
          <button type="submit">削除</button>
        </Form>
      </td>
    </tr>
  );
}





export default function Page() {
  const todos = useLoaderData<Todo[]>();
  const formRef = useRef<HTMLFormElement>(null);

  useEffect(() => {
    if (formRef.current) {
      formRef.current.reset();
    }
  }, [todos]); // Reset form when todos change, indicating a form submission happened

  return (
    <div className="container mx-auto px-4 py-5">
      <h1 className="text-2xl font-bold text-gray-900 mb-4">Todo List</h1>
      <Form ref={formRef} method="post" className="mb-6 flex">
        <input type="hidden" name="_method" value="create" />
        <input 
          type="text" 
          name="title" 
          placeholder="新しいTodo" 
          className="border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 rounded-md shadow-sm mr-4 flex-1"
        />
        <button 
          type="submit" 
          className="px-4 py-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded-lg"
        >
          Todoを追加
        </button>
      </Form>
      <table className="min-w-full leading-normal">
        <thead>
          <tr>
            <th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
              タイトル
            </th>
            <th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
              操作
            </th>
          </tr>
        </thead>
        <tbody>
          {todos.map((todo) => (
            <TodoRow key={todo.id} todo={todo} />
          ))}
        </tbody>
      </table>
    </div>
  );
}

気になるところメモった

ローダーのargsの型

import {LoaderFunctionArgs } from '@remix-run/node';
const getSupabaseClient = async (args: LoaderFunctionArgs) =>{}


//~~~~~~~~~~~~

export const loader: LoaderFunction = async (args) => {
  const supabase = await getSupabaseClient(args);

todoの型定義

// todo の型を定義
interface Todo {
  id: number;
  title: string;
}

テーブルの1行のコンポーネントの引数に型をセット

// Todo アイテムを表示するためのコンポーネント
function TodoRow({ todo }: { todo: Todo }) {return}

useLoaderDataの呼び出しにもジェネリックで配列表記[]をつけて記載する

export default function Page() {
  const todos = useLoaderData<Todo[]>();

<Form>にはref={formRef}をつける

依存するステートはuseLoaderDataのステートである[todos]です。

ボタン押すとアクションでSupabaseに値が送信されて、ローダーで読み取りして再度レンダリング、そしてuseEffectformRefが初期化されます。

export default function Page() {
  const todos = useLoaderData<Todo[]>();
  const formRef = useRef<HTMLFormElement>(null);

  useEffect(() => {
    if (formRef.current) {
      formRef.current.reset();
    }
  }, [todos]); 

再度、TODOアプリを作り直しました。
https://zenn.dev/harukii/articles/68b8a61cd46fcd

Discussion