🐈

Remixで再度Todoアプリを作る

2024/04/23に公開

Remixの実装方法は人によってみんなバラバラです。今回は公式の動画に寄せてTODOアプリを再度作りなおしました。

また公式見解ではファイルの設置にコロケーションを意識していますが、今回はすべての機能を1ページに集約しています。

Remixは書き方が柔軟なので、認証やデータベースへのアクセスはファイルをわけたり、ローダーとアクションを別のファイルに分けたりできます。

結局は全体の文章量は変わらないのでファイルをどこに置くのは最終的には個人の好みになるのです(あいまいになってる)

自分的にはファイルの数を減らしたいので、出来るだけ1ページにずらりと並んでる方が好きですね。

前回のTODOアプリ(UIUXがクソ)
https://zenn.dev/harukii/articles/23b60b9845e4ed

参考
https://github.com/himorishige/remix-form-example/blob/chapter/clearing-inputs/app/routes/todo.tsx

Remixのyoutube
https://www.youtube.com/watch?v=w2i-9cYxSdc&t=462s

前提:

  • supabase
  • Clerk認証
  • shadcn/ui

機能要件

  • todoアプリ
  • 新規作成
  • 削除
  • todoの読み込み

他の要件

  • ユーザー体験よくする
  • UIも少しは改善する
app\routes\todos.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import {
  Form,
  useActionData,
  useLoaderData,
  useNavigation,
  useFetcher
} 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: string;
  title: string;
  completed: boolean;
}

// Supabaseクライアントを生成するユーティリティ関数
const getSupabaseClient = async (args: ActionFunctionArgs) => {
  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 = async (args: LoaderFunctionArgs) => {
  try {
    const supabase = await getSupabaseClient(args);
    const { data, error } = await supabase.from('todos').select('*');
    if (error) {
      throw new Response(`Failed to fetch todos: ${error.message}`, { status: 500 });
    }
    return json(data);
  } catch (error) {
    console.error('Error loading todos:', error);
    throw new Response('Error loading todos', { status: 500 });
  }
};

// Todoアクションを処理する関数
export const action = async (args: ActionFunctionArgs) => {
  const formData = await args.request.formData();
  const supabase = await getSupabaseClient(args);
  const { userId } = await getAuth(args);

  const actionType = formData.get('_action');

  if (actionType === 'create') {
    const title = formData.get('title');
    if (typeof title !== 'string' || title.length === 0) {
      return json(
        { errors: { title: 'Title is required' } },
        { status: 422 }
      );
    }
    await supabase.from('todos').insert({ title, user_id: userId });
    return json({ success: true });
  }

  if (actionType === 'delete') {
    const id = formData.get('id');
    if (typeof id !== 'string' || id.length === 0) {
      return json({ errors: { id: 'ID is required' } }, { status: 422 });
    }
    await supabase.from('todos').delete().match({ id: id });
    return json({ success: true });
  }

  return json({ errors: { action: 'Unknown action' } }, { status: 400 });
};

// Todoページを構築するコンポーネント
const TodoPage = () => {
  const todos = useLoaderData<Todo[]>() || [];
  const actionData = useActionData<{ errors: { [key: string]: string } }>();
  const navigation = useNavigation();
  const isAdding = navigation?.formData?.get('_action') === 'create';
  const formRef = useRef<HTMLFormElement>(null);
  const titleRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (isAdding) {
      formRef.current?.reset();
      titleRef.current?.focus();
    }
  }, [isAdding]);

  return (
    <main className="flex flex-col items-center min-h-screen">
      <h1 className='text-2xl'>TODO</h1>
      {actionData?.errors && (
        <div>
          {Object.entries(actionData.errors).map(([key, error]) => (
            <span key={key}>{error}</span>
          ))}
        </div>
      )}
      <ul>
        {todos.map((todo) => (
          <TodoItem key={todo.id} item={todo} />
        ))}
        <li>
          <Form ref={formRef} replace method="post">
            <input ref={titleRef} type="text" name="title" placeholder="Add a todo" />
            <button type="submit" name="_action" value="create" disabled={isAdding}>
              Add
            </button>
          </Form>
        </li>
      </ul>
    </main>
  );
};

// Todoアイテムを表示するコンポーネント
const TodoItem = ({ item }: { item: Todo }) => {
  const fetcher = useFetcher();
  const isDeleting = fetcher.formData?.get('id') === item.id.toString() && fetcher.formData?.get('_action') === "delete";
  

  return (
    <li
      hidden={isDeleting}
    >
      {item.title}
      <fetcher.Form method="post" style={{ display: 'inline' }}>
        <input type="hidden" name="id" value={item.id} />
        <button type="submit" name="_action" value="delete" disabled={isDeleting}>
          X
        </button>
      </fetcher.Form>
    </li>
  );
};

export default TodoPage;



気になるところをピックアップしてみる

loaderとactionの型定義

Remixでは、データをロードするためにloaderを使い、アクションを処理するためにactionを使います。これらの関数には、それぞれ特定の型があります。

LoaderFunctionArgs: loader関数に渡される引数の型です。データベースからデータを取得したり、初期値を設定したりするために使います。
ActionFunctionArgs: action関数に渡される引数の型です。ユーザーからのフォームデータを処理したり、データベースに変更を加えたりするために使います。

getAuthとargsの型

ClerkのgetAuthは、ユーザーの認証情報を取得するための関数です。LoaderFunctionArgsやActionFunctionArgsを引数として受け取り、認証情報を取得します。これにより、ユーザーごとに異なるデータを処理することができます。

import { getAuth } from '@clerk/remix/ssr.server';

const getUserAuth = async (args: LoaderFunctionArgs | ActionFunctionArgs) => {
  return await getAuth(args);
};

上記のように、getAuthLoaderFunctionArgsまたはActionFunctionArgsを受け取って、ユーザーの認証情報を取得します。

useActionDataの型定義

useActionDataは、Remixのアクションから返されたデータを取得するためのフックです。型定義を行うことで、アクションから返されるデータの型を指定できます。例えば、エラーメッセージや成功メッセージなどを処理する場合、型定義を行うことでデータの一貫性を保ちます。

import { useActionData } from '@remix-run/react';

const actionData = useActionData<{ errors: { [key: string]: string } }>();

この例では、useActionDataにエラーの型を定義し、エラーメッセージが期待される場所で安全に使用できます。

todoの型定義

// Todoの型を定義
interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

actionの条件分岐はformData.get('_action');で分岐する

action関数では、フォームから送られてきたデータに基づいて条件分岐を行います。Remixでは、フォームデータからアクションを決定するためにformData.get('_action')を使用します。

アンダースコアでこれはactionの条件分岐に使うことが明示的になる。

const action = async (args: ActionFunctionArgs) => {
  const formData = await args.request.formData();
  const actionType = formData.get('_action');

  if (actionType === 'create') {
    // Todoアイテムを作成
  } else if (actionType === 'delete') {
    // Todoアイテムを削除
  } else {
    throw new Error("Unknown action");
  }
};

この例では、_actionフィールドを使って、アクションの種類を特定しています。これにより、特定の処理を行うことができます。

      <fetcher.Form method="post">
        <input type="hidden" name="id" value={item.id} />
        <button type="submit" name="_action" value="delete" disabled={isDeleting}>
    </fetcher.Form>

上記のコードスニペットは、ReactとRemixのuseFetcherを使って、非同期アクションを行うためのフォームを示しています。このフォームは、method="post"を指定しており、これはaction関数を呼び出しになります。

fetcher.Form
RemixのuseFetcherフックを使って、フォームを作成しています。このfetcher.Formは、通常のフォームと同様に動作しますが、非同期でサーバーにリクエストを送信し、その結果を取得します。fetcherは、通常のフォーム送信と異なり、ページのリロードなしにリクエストを行います。

method="post"
HTTPメソッドの1つである"POST"を指定しています。このメソッドは、データをサーバーに送信するために使われ、一般的にデータの作成や更新、削除に使用されます。ここでは、アイテムの削除アクションに使われていると考えられます。

input type="hidden"
input要素は、ユーザーに見えないがサーバーに送信されるデータを持っています。この場合、削除したいアイテムのidを指定しています。name="id"で、サーバーサイドでこの値を取得するための名前を定義し、value={item.id}で、対象となるアイテムのIDを設定しています。

button type="submit"
このボタンは、フォームを送信するためのものです。type="submit"によって、ボタンが押されるとフォームが送信され、サーバーにPOSTリクエストが送られます。

name="_action"とvalue="delete"
この属性は、サーバーサイドでアクションを判別するためのものです。name="_action"で、アクションの種類を指定し、value="delete"で、削除アクションであることを示しています。

イメージ的にはJSONでいうところのinput_hiddenを記述するごとに内部的に

{form:[{_action:"delete"},{title,""},{id,""}]}

というな属性を持たせます。

disabled={isDeleting}
この属性は、ボタンが無効化されているかどうかを決定します。isDeletingがtrueであれば、ボタンは無効化されます。これにより、重複した削除操作を防ぐことができます。

楽観的UIの実装

楽観的UIは、ユーザーの操作に即座に応答し、バックエンドの処理を待たずに即座にフィードバックを与えることで、ユーザー体験を向上させます。楽観的UIの実装では、アクションが成功することを前提にUIを更新し、バックエンドの結果が返されたときに修正を行います。

// Todoアイテムを表示するコンポーネント
const TodoItem = ({ item }: { item: Todo }) => {
  const fetcher = useFetcher();
  const isDeleting = fetcher.formData?.get('id') === item.id.toString() && fetcher.formData?.get('_action') === "delete";
  

  return (
    <li
      hidden={isDeleting}
    >
      {item.title}
      <fetcher.Form method="post" style={{ display: 'inline' }}>
        <input type="hidden" name="id" value={item.id} />
        <button type="submit" name="_action" value="delete" disabled={isDeleting}>
          X
        </button>
      </fetcher.Form>
    </li>
  );
};

この例では、アイテムが削除されたときに即座に隠して、バックエンドの処理を待たずにUIを更新します。削除が失敗した場合、アイテムを再表示して再試行を促します。

TodoItemの呼び出しにはKEYをつける

{todos.map((todo) => (
  <TodoItem key={todo.id} item={todo} />
))}

keyプロパティ
keyは、Reactコンポーネントの一意識別子で、コンポーネントが配列の中でどの位置にあるかをReactが理解するために使われます。配列の要素をレンダリングする際、Reactはkeyを使って、コンポーネントの追加、削除、並べ替えなどを効率的に処理します。

このコードでは、todo.idをkeyとして使用しています。これは、各Todoアイテムに一意のIDがあることを前提としています。keyが一意でないと、Reactが効率的に再レンダリングできず、バグや警告が発生する可能性があります。

useNavigationフックの使い方

useNavigationフックは、ナビゲーションの状態を追跡し、現在の操作が進行中かどうかを確認するために使います。これを使って、フォームのリセットやフォーカスの設定を行います。

import { useNavigation } from '@remix-run/react';
import { useEffect, useRef } from 'react';

const TodoPage = () => {
  const navigation = useNavigation();
  const formRef = useRef<HTMLFormElement>(null);
  const titleRef = useRef<HTMLInputElement>(null);

  const isAdding = navigation.formData?.get('_action') === 'create';

  useEffect(() => {
    if (isAdding) {
      formRef.current?.reset();
      titleRef.current?.focus();
    }
  }, [isAdding]);
};

useNavigationを使って、フォームのアクションがcreateかどうかを確認します。そして、アクションが進行中であるとき、フォームをリセットしてタイトルにフォーカスを設定します。つまり、新規追加された瞬間にフォームのアクションがcreateになるので新規作成のテキストフォームがリセットされてフォーカスもテキストフォームにセットされます。

Discussion