Remixの基本 – Formコンポーネントの使い方

RemixとSupabaseを使ってRemixのFormコンポーネントを利用したRemix流のCRUD処理について、公式ドキュメントと公式YouTubeチャンネルを参考に学んでいきます。
2022.04.23

はじめに

こんにちは、CX事業本部MAD事業部の森茂です。

Reactアプリケーションでフォームを扱う場合は、onChangeonBluronSubmitイベントを処理し、各ステートを管理する必要があります。React Hook Formなどフォームを抽象化できるライブラリを利用しない場合はとくに多くの手続きを用意する必要があり、意外と設計や実装に悩まされる部分でもあります。

それに対し、Remixはどこか懐かしい?伝統的な?フォームの使い方に回帰させるような方法を採用しています。個人的な感覚としては、フレームワークを利用せずにPHPスクリプトで直接フォームを処理していたようなイメージでしょうか。RemixのFormはRemixの基本思想であるWeb標準、Web API(Fetch API)に沿った実装が色濃く現れている部分でもあるとも言えそうです。

今回は公式ドキュメント公式YouTubeチャンネルの解説を参考にRemixのFormを使ったRemix流のCRUD処理の扱い方を学んでいきます。

  1. LoaderFunctionとuseLoaderData
  2. Form + ActionFunctionとuseActionData
  3. 複数のFormを利用する
  4. ローディング、待機時のUI(Pendig UI)
  5. Submit後のフォームリセット
  6. useFetcherを利用したFormの並列処理
  7. 楽観的UI(Optimistic UI)

0. 事前準備

Supabaseのセットアップ

今回はCRUD処理のためにSupabaseを利用します。Supabaseについては以前Next.jsを利用した情報を記事にしていますので下記も参考にしてください。

今回も上記記事で作成したのと同じ構成のテーブルを用意しました。新規作成時に用意されるものへtitleだけ追加したものとなっています。

テーブル名 todo

Name Type Default Value Primary
id int8 ---
title text NULL ---
created_at timestamptz now() ---

また、Project API keyURLも同様にメモをしておいてください。

Remixのセットアップ

Remixアプリケーションを用意します。今回は初期構築を行ったものをGitHubへ公開していますのでそちらをCustom Stackとして利用、もしくはクローンして利用していただくか下記手順で構築を行ってください。

Remix Custom Stacksを利用する場合

あらかじめ用意してあるRemix Custom Stacksを利用してセットアップする方法です。一番Remix wayに則った手法かもしれません。

$ npx create-remix@latest --template himorishige/remix-form-example

$ cd remix-form-example
$ cp .env.example .env

.envファイルにSupabaseから取得したProject API keyURLを追記します。

.env

SUPABASE_URL=https://********.supabase.co
SUPABASE_ANON_KEY=eyJ********************

開発サーバーを起動します。

$ npm run dev

http://localhost:3000/todoにアクセスすることでTODOの入力画面が表示されます。

Error: supabaseUrl is required.Error: supabaseKey is required.といったエラーが出た場合はSupabaseから取得したキーが誤っている可能性があるため再度確認してみてください。

試しにデータを登録してみましょう。まだ削除ボタンがありませんが1件のデータが登録できました。

これで事前準備完了です。Remixの初期構築時と異なるファイルについては次項目新規にRemixを用意する場合に記載しています。

GitHubからクローンする場合

GitHubからソースコードをクローンして展開します。

$ git clone git@github.com:himorishige/remix-form-example.git
or
$ git clone https://github.com/himorishige/remix-form-example.git

$ cd remix-form-example
$ npm install
$ cp .env.example .env

クローン後に行う作業は前項Remix Custom Stacksを利用する場合と同様になります。

新規にRemixを用意する場合

Remixを新規にインストールします。今回は一番軽量なbasicsタイプのRemix App Serverでセットアップをします。

$ npx create-remix@latest

? Where would you like to create your app? remix-form-example
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix if you're unsure; it's easy to change deployment targets. Remix App Server
? Do you want me to run `npm install`? Yes

$ cd remix-form-example

Supabaseへの接続に必要なパッケージをインストールします。

$ npm install @supabase/supabase-js

プロジェクトルートに.envファイル作成しSupabaseから取得したProject API keyURLを記載します。

.env

SUPABASE_URL=https://********.supabase.co
SUPABASE_ANON_KEY=eyJ********************

app/utils/supabaseClient.server.ts

Supabaseへ接続するためのクライアントを作成します。Remixではファイル名を*.server.ts(x)とすることでサーバーサイドのみでバンドルするファイルとなります。

以降追加するソースコードは下記から直接該当部分を確認することもできます。 himorishige/remix-form-example

app/utils/supabaseClient.server.ts

import { createClient } from '@supabase/supabase-js';

export const client = createClient(
  process.env.SUPABASE_URL as string,
  process.env.SUPABASE_ANON_KEY as string
);

app/models/task.server.ts

次にSupabaseに接続しデータベースから取得、作成、削除する関数を用意しておきます。簡易的な実装のため今回エラーハンドリング等は省略しています。またサーバーサイドでのみ利用するためこちらもファイル名は*.server.tsとしています。

app/models/task.server.ts

import { client } from '~/utils/supabaseClient.server';

export type Todo = {
  id: number;
  title: string;
  created_at: string;
};

// タスクの一覧を取得
export const getTaskList = async () => {
  const { data: todo } = await client
    .from<Todo>('todo')
    .select('*')
    .order('created_at', { ascending: true });
  return todo;
};

// タスクを作成する
export const createTask = async (title: string) => {
  const { data: todo } = await client.from<Todo>('todo').insert({ title });
  return todo;
};

// タスクを削除する
export const deleteTask = async (id: number) => {
  const { data: todo } = await client.from<Todo>('todo').delete().eq('id', id);
  return todo;
};

app/routes/todo.tsx

最後にタクスを管理するページを用意します。

app/routes/todo.tsx

import type { ActionFunction, LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { Form, useActionData, useLoaderData } from '@remix-run/react';
import type { Todo } from '~/models/task.server';
import { createTask, getTaskList } from '~/models/task.server';

// ページ生成時に読み込まれるデータ
// タスクの一覧を取得する
export const loader: LoaderFunction = async () => {
  const todo = await getTaskList();
  return json(todo);
};

// フォームのSubmit時に実行される
export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const title = formData.get('title');

  if (typeof title !== 'string' || title.length === 0) {
    return json({ errors: { title: 'Title is required' } }, { status: 422 });
  }

  return await createTask(title);
};

// ページコンポーネント
const TodoPage = () => {
  // loaderが取得したデータを受け取る
  const todo = useLoaderData<Todo[] | null>();
  // actionからデータを受け取る
  const actionData = useActionData<{ errors: { title: string } }>();

  if (!todo) return <div>no items</div>;

  return (
    <main>
      <h1>TODO</h1>
      <ul>
        {todo.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
        <li>
          <Form replace method="post">
            <input type="text" name="title" />
            <button type="submit">Add</button>
          </Form>
        </li>
        {actionData?.errors && <span>{actionData.errors.title}</span>}
      </ul>
    </main>
  );
};

export default TodoPage;

これで下準備が完了です。開発サーバーを起動します。

$ npm run dev

http://localhost:3000/todoにアクセスすることでTODOの入力画面が表示されます。

Error: supabaseUrl is required.Error: supabaseKey is required.といったエラーが出た場合はSupabaseから取得したキーが誤っている可能性があるため再度確認してみてください。

試しにデータを登録してみましょう。まだ削除ボタンがありませんが1件のデータが登録できました。

1. LoaderFunctionとuseLoaderData

Remixはサーバーサイドレンダリング(以後SSR)が基本になります。もちろん一般的なReactアプリケーションのようにクライアントサイドでレンダリングを行いデータを取得することも可能ですが、Remixのメリットを活かす場合はLoader Functionを使い、ページ生成時にデータを取得してレンダリングを行います。

Remixでは各種loaderを使うことでサーバーサイドでの処理を表示側へ反映します。よく比較に出されるNext.jsだとgetServerSidePropsと同じような扱いになるでしょうか。

今回はタスクの一覧を取得する関数を実行し、その結果をRemixのjson helperを用いてjsonデータとして表示コンポーネント側に渡すことができます。Remixではデータの受け渡しやエラーハンドリングなどさまざまな箇所でJson helperを使います。(もちろん利用しなくてもデータを送ることは可能です)

app/routes/todo.tsx

//...
// タスクの一覧を取得する
export const loader: LoaderFunction = async () => {
  const todo = await getTaskList();
  return json(todo);
};
//...

コンポーネント側ではuseLoaderDataを利用してデータを受け取ります。

app/routes/todo.tsx

// loaderが取得したデータを受け取る
const todo = useLoaderData<Todo[] | null>();
//...

ここで注意したいのはuseLoaderData自体にはloaderの型情報は渡ってきません。そのためジェネリクスを用いて開発者が想定している型を渡しているだけの状態です。プロダクションでの利用時など厳格にデータを扱いたい場合は各境界(loaderaction)ごとに何らかのバリデーション処理を入れることをオススメします。

// loaderが取得したデータをunknown型で受け取る
const todo = useLoaderData<unknown>();
// ...バリデーション処理を行ってTodo[]型へ絞ってからデータを利用する

動作確認

Loader DataはSSR時に処理されるため、ブラウザに渡る時点でSupabaseから取得したデータが生成されています。

2. Form + ActionFunctionとuseActionData

RemixではFormコンポーネントを利用してデータの変異(作成、更新、削除)を行うことができます。HTMLのformタグを包括したコンポーネントになっておりHTMLタグと同じように動作させることが可能です。

参考ソースコード(mainブランチ)
himorishige/remix-form-example

RemixではほとんどがWeb APIに準拠したAPIで構成されているのでMDNが公式ドキュメントのように利用できます:)

今回はタスクの登録用途としてタイトルのinputフィールドとAddボタンを設置しています。actionが未指定の場合は自身へ指定したmethod="post"を用いて通信を行います。

app/routes/todo.tsx

<Form replace method="post">
  <input type="text" name="title" />
  <button type="submit">Add</button>
</Form>

replaceFormコンポーネント独自のパラメーターです。ブラウザの履歴を置き換えるため「戻る」ボタンを押してもボタン押下直前のページへ戻らせたくない場合に付与します。今回のようにページ遷移せずに何度も送信処理されることを想定している場合などに有効です。

Formコンポーネントから送られたデータはAction Functionへ渡されます。今回はFormタグでaction="**"を指定していないので自身のAction Functionで処理されます。

app/routes/todo.tsx

// フォームのSubmit時に実行される
export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const title = formData.get('title');

  // 簡易的なバリデート処理
  if (typeof title !== 'string' || title.length === 0) {
    return json({ errors: { title: 'Title is required' } }, { status: 422 });
  }

  return await createTask(title);
};

POSTで渡されたデータはRequestFormDataとして扱われます。RequestFormDataもまたWeb APIの1つとなるためMDNのドキュメントにてどういった仕様を持っているか、どのように扱うことができるのかを確認できます。

actionでは受け取ったデータのバリデートを行い、値に問題がある場合はエラーメッセージを返しています。Remixではthrow Errorを行ってError BoundaryCatch Boundaryを利用して受け取る形もよく利用されます。

actionからreturnされた値はコンポーネント側ではuseActionDataで取得、利用できます。今回はシンプルにエラーメッセージを表示させる形にしています。

app/routes/todo.tsx

{actionData?.errors && <span>{actionData.errors.title}</span>}

RemixのJson helperではエラーコードも送ることができるのでCSRなReactアプリケーションでは対応のできないエラーコードも扱いやすくなっています。

動作確認

改めてデータを登録してみましょう。Addボタンを押下時にPOST通信が自身に送られます。ブラウザの開発ツールなどでネットワーク通信を見てみるとPOSTでデータが送信され、GETでSupabaseからデータを取得する流れが確認できます。

また、これはブラウザとサーバーの通信のみで実行されるためブラウザのJavaScriptをOFFにした状態でも実行可能です。ChromeでJavaScriptを無効にした場合でもjsファイルはエラーとなっていますが、POSTの処理とデータの取得・表示が行われているのがわかります。

ReactアプリケーションでJavaScriptをOFFにすること自体想定することはないかと思いますが、RemixがWeb標準の技術を利用しているのがわかる部分でもあります。

3. 複数のFormを利用する

前章ではタスクをAddボタンから登録する実装を確認しました。この章では削除ボタンをタスクごとに設置して1つのページで複数のFormを利用する方法を確認します。

コンポーネント側に削除ボタンを追加します。追加ボタンのFormとは別のactionとなるため、今回はsubmitボタンにname="action"value="delete"を付与してactionFunction側で条件分岐を行います。type="hidden"のinputタグに付与する形でもOKです。また、タスクの削除にはタスクのidが必要になるためinput type="hidden" name="id" value={item.id}としてデータを送信します。

app/routes/todo.tsx

//...
{todo.map((item) => (
  <li key={item.id}>
    {item.title}
    <Form replace method="post" style={{ display: 'inline' }}>
      <input type="hidden" name="id" value={item.id} />
      <button type="submit" name="action" value="delete">
        X
      </button>
    </Form>
  </li>
))}
//...

同様に、追加ボタンにもname="action" value="create"を追記します。

app/routes/todo.tsx

<li>
  <Form replace method="post">
    <input type="text" name="title" />
    <button type="submit" name="action" value="create">
      Add
    </button>
  </Form>
</li>

次にactionFunctionの実装を変更します。name="action"valueを利用して登録と削除の条件分岐を行います。

app/routes/todo.tsx

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

  // name="action"のvalueを利用して条件分岐
  switch (formData.get('action')) {
    // タスクの追加
    case 'create': {
      const title = formData.get('title');
      if (typeof title !== 'string' || title.length === 0) {
        return json(
          { errors: { title: 'Title is required' } },
          { status: 422 }
        );
      }

      return await createTask(title);
    }

    // タスクの削除
    case 'delete': {
      const id = formData.get('id');
      if (typeof id !== 'string' || id.length === 0) {
        return json({ errors: { title: 'Id is required' } }, { status: 422 });
      }

      return await deleteTask(Number(id));
    }

    // エラー
    default: {
      return json({ errors: { title: 'Unknown action' } }, { status: 400 });
    }
  }
};

動作確認

actionFunctionが実装できたところで動作を確認します。追加ボタン、削除ボタンごとにPOST処理が走り、それぞれの処理が完了時にはGET処理で一覧情報を再取得するようになりました。

削除時の動作を確認するためにもう1つ実験を追加してみます。deleteの条件分岐の中にタイマーを挿入して返答速度を意図的に遅くしておきます。

app/routes/todo.tsx

case 'delete': {
  const id = formData.get('id');
  if (typeof id !== 'string' || id.length === 0) {
    return json({ errors: { title: 'Id is required' } }, { status: 422 });
  }

  await new Promise((resolve) => setTimeout(resolve, Math.random() * 2000));

  return await deleteTask(Number(id));
}

この状態で削除ボタンを連続で押下してみると処理完了前のPOSTについては自動的にキャンセルされ最後に送られたPOSTのみが送信され、その完了を待ってGETが実行されていることが確認できます。これはRemixの機能ではなくWeb APIを利用するブラウザの機能ですが、Formを利用するとこういった細かいところも自動的に処理してくれるのも大きいところです。

4. ローディング、待機時のUI(Pending UI)

各ボタンについては連続で押下されても途中はすべてキャンセルされ最後のアクションのみが実行されましたが、追加ボタンについては連続で投稿できる可能性があるためさらに制御を考える必要があります。

Remixではページ遷移やデータの変更が行われる際の状態をuseTransitionを利用して取得できます。

const transition = useTransition();
transition.state;

transition.stateとして下記のような遷移状態が取得できます。

通常の遷移

idle → loading → idle

GETでの遷移

idle → submitting → idle

POST、PUT、PATCH、DELETEでの遷移

idle → submitting → loading → idle

また、transition.submissionからsubmitting時のデータを受け取ることができるため、今回はそれらを利用してローディングや待機状態を制御します。

app/routes/todo.tsx

//...
const TodoPage = () => {
  const todo = useLoaderData<Todo[] | null>();
  const actionData = useActionData<{ errors: { title: string } }>();
  // useTransitionを利用
  const transition = useTransition();
  // submitting時のデータがあり、
  // FormDataからactionがcreateの場合にタスク追加状態と判断する
  const isAdding =
    transition.submission &&
    transition.submission.formData.get('action') === 'create';

  if (!todo) return <div>no items</div>;

  return (
    <main>
      {/* 省略 */}
        <li>
          <Form replace method="post">
            <input type="text" name="title" />
            <button
              type="submit"
              name="action"
              value="create"
              {/* 追加途中はボタンを無効化 */}
              disabled={isAdding}
            >
              {/* 追加途中はボタンテキストを変更 */}
              {isAdding ? 'Adding...' : 'Add'}
            </button>
          </Form>
        </li>
      {/* 省略 */}
    </main>
  );
};

//...

動作確認

動作を確認してみます。今回もcreateの条件分岐内にタイマーを入れて動作を確認するのがわかりやすいでしょう。

await new Promise((resolve) => setTimeout(resolve, Math.random() * 2000));

タスクを入力してAddボタンを押してみます。

Addボタンを押下後にデータが反映されるまでボタンが無効化されることを確認できました。

5. Submit後のフォームリセット

Addボタンを押してタスクを登録できるようになりましたが、inputフィールドには事前に入力したタスクが残っていたりと続けて入力する際にまだ使いにくい部分が残っています。
UIとして使いやすいよう入力後にフィールドをリセットし、フォーカスをフィールドにあてる部分を実装します。

useRefuseEffectを利用してページ表示時と登録が完了した時点でフォームをリセットしフォーカスへ移動するように実装を行います。この辺りは一般的なReactアプリケーションと同様の手法とほとんど変わりません。

app/routes/todo.tsx

const TodoPage = () => {
  // ...
  const isAdding =
    transition.submission &&
    transition.submission.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>
      <h1>TODO</h1>
      <ul>
        {/* 省略 */}
        <li>
          <Form ref={formRef} replace method="post">
            <input ref={titleRef} type="text" name="title" />
            <button
              type="submit"
              name="action"
              value="create"
              disabled={isAdding}
            >
              {isAdding ? 'Adding...' : 'Add'}
            </button>
          </Form>
        </li>
        {actionData?.errors && <span>{actionData.errors.title}</span>}
      </ul>
    </main>
  );
};
// ...

動作確認

早速動作を試してみます。

タスクを入力してAddボタンを押下、データ登録が完了後にフォームはリセットされフォーカスも入力エリアに移動しました。

6. useFetcherを利用したFormの並列処理

ここまでの実装で基本的な動きは実装できましたが、実はこのままでは複数のタスクを同時に削除しようとした場合に問題が起きてしまいます。

削除時に該当のタスクが透過状態になるようスタイルを追加して問題を検証してみます。

app/routes/todo.tsx

{todo.map((item) => (
  <li
    key={item.id}
    style={{
      opacity:
        transition.submission?.formData.get('id') === item.id.toString()
          ? 0.25
          : 1,
    }}
  >
    {item.title}
    <Form replace method="post" style={{ display: 'inline' }}>
      <input type="hidden" name="id" value={item.id} />
      <button type="submit" name="action" value="delete">
        X
      </button>
    </Form>
  </li>
))}

今回もdeleteの条件分岐内にタイマーを入れて動作を確認するのがわかりやすいでしょう。

await new Promise((resolve) => setTimeout(resolve, Math.random() * 2000));

動作確認

削除対象のスタイルが変わるのを確認できました。1つずつ削除する分には何も問題はなさそうです。続けて連続して削除を試してみます。

削除ボタンを押したタスクが透過になりますが、削除される前に他のタスクを削除するとスタイルの変更はキャンセルされ、最後にまとめて削除処理が行われてしまいました。

これはuseTransitionのライフサイクルは1つのため、並列な処理を行うことができず1つずつ順に処理されていること(transition.submissionは都度キャンセルされ再生成されている)に起因します。

このようにFormを同時に利用することは多くないかもしれませんが、Remixでは複数のFormを別々に管理する場合やページ遷移のないFormやデータのやり取りを行うためにuseFetcherというカスタムフックが用意されています。

それぞれのfetcherは別々のライフサイクルを持ちfetcher.statefetcher.submissionなどtransition.statetransition.submissionと同様の処理を行うことが可能です。

なお、公式ドキュメントでは下記のような場合にuseFetcherが便利であると記載されています。

  • ポップアップ、動的なフォームなどページ遷移を持たないデータのフェッチ
  • ページ遷移(自身へを含む)せずにデータをactionに送信したい場合(ニュースレターのサインアップコンポーネントなど)
  • 複数のボタンをクリックするとすべて同時に並列に処理されるTodoアプリケーションのようなもの
  • 無限スクロールのコンテナー

useFetcherの実装

それでは、useFetcherを利用した形にコンポーネントを書き換えていきます。タスクの要素1つ1つでuseFetcherを利用するためTodoItemコンポーネントへ分割します。

app/routes/todo.tsx

//...
const TodoItem: FC<{ item: Todo }> = ({ item }) => {
  // useTransitionの代わりにuseFetcherを利用
  const fetcher = useFetcher();
  // transitionをfetcherに差し替える
  const isDeleting =
    fetcher.submission?.formData.get('id') === item.id.toString();

  return (
    <li
      key={item.id}
      style={{
        opacity: isDeleting ? 0.25 : 1,
      }}
    >
      {item.title}
      {/* fetcherが用意するFormを利用 */}
      <fetcher.Form replace method="post" style={{ display: 'inline' }}>
        <input type="hidden" name="id" value={item.id} />
        <button type="submit" name="action" value="delete">
          X
        </button>
      </fetcher.Form>
    </li>
  );
};
//...

タスク一覧の部分もTodoItemコンポーネントを利用する形に書き換えます。

app/routes/todo.tsx

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

これで、各タスクがそれぞれuseFetcherを利用したライフサイクルの中に入れることができました。この状態で再度連続したタスクの削除を試してみます。

今回もdeleteの条件分岐内にタイマーを入れて動作を確認するのがわかりやすいでしょう。

await new Promise((resolve) => setTimeout(resolve, Math.random() * 2000));

動作確認

連続して削除したところ透過するスタイルも個別に反映され、削除も項目ごとにPOSTされていくのが確認できました。削除される順番もばらばらになりライフサイクルも別々になっていることがわかります。

7. 楽観的UI(Optimistic UI)

RemixのFormでは楽観的UIについても比較的容易に実装することが可能です。

楽観的UIについては下記ブログ記事がとても参考になります。

楽観的UIを実装することでユーザーが操作した内容は即座に反映される(見かけ上)ため、逆にローディング、待機時のUIは不要となってきます。
また、今回は楽観的UIの挙動と削除時のエラーを再現するためにactionFunctiondelete条件分岐の中で意図的に50%の確率でエラーを返すようにしておきます。

app/routes/todo.tsx

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

  switch (formData.get('action')) {
    case 'create': {
      // ...
    }
    // 楽観的UIの動作検証のため削除処理が50%の確率でErrorをthrowさせる
    case 'delete': {

      await new Promise((resolve) => setTimeout(resolve, Math.random() * 2000));

      try {
        if (Math.random() > 0.5) {
          throw new Error();
        }
        const id = formData.get('id');
        if (typeof id !== 'string' || id.length === 0) {
          return json({ errors: { title: 'Id is required' } }, { status: 422 });
        }

        return await deleteTask(Number(id));
      } catch (e) {
        // Errorがthrowされた場合エラーメッセージをactionDataとして渡す
        return json(
          { errors: { title: 'Something went wrong' } },
          { status: 400 }
        );
      }
    }
    // ...
  }
};

const TodoPage = () => {
  const todo = useLoaderData<Todo[] | null>();
  const actionData = useActionData<{ errors: { title: string } }>();
  // ...

  useEffect(() => {
    // タスク登録後にリセットしていた部分をAddボタン押下後すぐに行うように変更
    if (isAdding) {
      formRef.current?.reset();
      titleRef.current?.focus();
    }
  }, [isAdding]);

  if (!todo) return <div>no items</div>;

  return (
    <main>
      <h1>TODO</h1>
      <ul>
        {todo.map((item) => (
          <TodoItem item={item} key={item.id} />
        ))}
        {/* Addボタンを押下後すぐに入力データを仮に表示する */}
        {/* 処理完了後は登録されたデータが表示されこの部分は非表示となる */}
        {isAdding && (
          <li>
            {transition.submission.formData.get('title')}
            <button disabled>X</button>
          </li>
        )}
        <li>
          <Form ref={formRef} replace method="post">
            <input ref={titleRef} type="text" name="title" />
            <button
              type="submit"
              name="action"
              value="create"
              disabled={isAdding}
            >
              {/* ボタンのラベルは変化させずそのまま */}
              Add
            </button>
          </Form>
        </li>
        {actionData?.errors && <span>{actionData.errors.title}</span>}
      </ul>
    </main>
  );
};

const TodoItem: FC<{ item: Todo }> = ({ item }) => {
  const fetcher = useFetcher();
  const isDeleting =
    fetcher.submission?.formData.get('id') === item.id.toString();

  // actionFunctionからエラーが返った場合のフラグを用意
  const isFailedDeletion = fetcher.data?.errors;

  return (
    {/* 削除ボタン押下時に即hidden属性で要素を隠す */}
    {/* actionFunctionで削除が失敗した場合は文字色を赤に変更 */}
    <li
      key={item.id}
      hidden={isDeleting}
      style={{ color: isFailedDeletion ? 'red' : '' }}
    >
      {item.title}
      <fetcher.Form replace method="post" style={{ display: 'inline' }}>
        <input type="hidden" name="id" value={item.id} />
        {/* actionFunctionで削除が失敗した場合は文字をRetryに変更 */}
        <button type="submit" name="action" value="delete">
          {isFailedDeletion ? 'Retry' : 'X'}
        </button>
      </fetcher.Form>
    </li>
  );
};
//...

動作確認

タスクの登録時、今まではAddボタンを押下したあとAddingとボタン名が変わり、Supabaseへ登録完了後に一覧へ反映されていました。今回楽観的UIを実装したことでAddボタンを押下するとすぐに一覧へ反映され、裏側では登録完了後に正式なデータへ差し替わるようになっています。

続けて削除を見ていきます。今までは削除ボタンを押下したあと、文字が透過され、処理完了後に一覧から消えていましたが、今回の実装で削除ボタンを押下後すぐに一覧から消えていきます。50%の確率で削除処理が失敗するようにしているため、エラーが返った場合のみ赤字、Retryボタンに書き換わったものが一覧へ再度表示されるようになりました。

おまけ

ここまでRemixが用意する素のAPIを利用してきましたが、RemixのFormを抽象的に扱うことのできるRemix Validated Formを利用することで記述量を大幅に減らすこともできます。またRemix Validated FormはZodやYupと連携することでバリデーション処理も簡単に組み込むことができるので実際の運用時にはこちらの利用もオススメです。

22.05.07追記

Remix Formがリリースされました。

Remix Validated Formとは別のFormライブラリとしてRemix Formがリリースされました。
Remix Validated Form同様にZodと連携することもできActionsの記載量を大幅に減らせるライブラリです。サンプルが充実しているのでこちらも試していきたいところです。

さいごに

RemixのFormを使ったRemix流のCRUD処理の流れを学んでみました。(Updateは実装しませんでしたが。。)

RemixにはFormuseTransitionuseFetcherだけでもまだまだ多くの機能を持っています。そのほとんどがWeb APIに沿った実装を取っているので、公式ドキュメントだけでなくMDNも参考にすることでRemixアプリケーションの実装を進めていくことができるのは大きなメリットとなります。
また、Remixを使ってみることでWeb標準の技術(Web API)のより深い機能を知るきっかけにもなるかもしれません。

公式ドキュメント公式YouTubeチャンネルでも詳しく解説されているのでぜひそちらも参考にRemixの世界を体験してみてください。

参考資料