この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
はじめに
こんにちは、CX事業本部MAD事業部の森茂です。
Reactアプリケーションでフォームを扱う場合は、onChange
やonBlur
、onSubmit
イベントを処理し、各ステートを管理する必要があります。React Hook Formなどフォームを抽象化できるライブラリを利用しない場合はとくに多くの手続きを用意する必要があり、意外と設計や実装に悩まされる部分でもあります。
それに対し、Remixはどこか懐かしい?伝統的な?フォームの使い方に回帰させるような方法を採用しています。個人的な感覚としては、フレームワークを利用せずにPHPスクリプトで直接フォームを処理していたようなイメージでしょうか。RemixのForm
はRemixの基本思想であるWeb標準、Web API(Fetch API)に沿った実装が色濃く現れている部分でもあるとも言えそうです。
今回は公式ドキュメント、公式YouTubeチャンネルの解説を参考にRemixのForm
を使ったRemix流のCRUD処理の扱い方を学んでいきます。
- LoaderFunctionとuseLoaderData
- Form + ActionFunctionとuseActionData
- 複数のFormを利用する
- ローディング、待機時のUI(Pendig UI)
- Submit後のフォームリセット
- useFetcherを利用したFormの並列処理
- 楽観的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 key
とURL
も同様にメモをしておいてください。
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 key
とURL
を追記します。
.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 key
とURL
を記載します。
.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
を使い、ページ生成時にデータを取得してレンダリングを行います。
- 参考ソースコード(mainブランチ)
himorishige/remix-form-example
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
の型情報は渡ってきません。そのためジェネリクスを用いて開発者が想定している型を渡しているだけの状態です。プロダクションでの利用時など厳格にデータを扱いたい場合は各境界(loader
、action
)ごとに何らかのバリデーション処理を入れることをオススメします。
// 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>
replace
はForm
コンポーネント独自のパラメーターです。ブラウザの履歴を置き換えるため「戻る」ボタンを押してもボタン押下直前のページへ戻らせたくない場合に付与します。今回のようにページ遷移せずに何度も送信処理されることを想定している場合などに有効です。
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で渡されたデータはRequest
のFormData
として扱われます。Request
、FormData
もまたWeb APIの1つとなるためMDNのドキュメントにてどういった仕様を持っているか、どのように扱うことができるのかを確認できます。
action
では受け取ったデータのバリデートを行い、値に問題がある場合はエラーメッセージを返しています。Remixではthrow Error
を行ってError BoundaryやCatch 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
を利用する方法を確認します。
- 参考ソースコード(multiple-formsブランチ)
himorishige/remix-form-example at chapter/multiple-forms
コンポーネント側に削除ボタンを追加します。追加ボタンの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)
各ボタンについては連続で押下されても途中はすべてキャンセルされ最後のアクションのみが実行されましたが、追加ボタンについては連続で投稿できる可能性があるためさらに制御を考える必要があります。
- 参考ソースコード(pending-uiブランチ)
himorishige/remix-form-example at chapter/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として使いやすいよう入力後にフィールドをリセットし、フォーカスをフィールドにあてる部分を実装します。
- 参考ソースコード(pending-uiブランチ)
himorishige/remix-form-example at chapter/clearing-inputs
useRef
とuseEffect
を利用してページ表示時と登録が完了した時点でフォームをリセットしフォーカスへ移動するように実装を行います。この辺りは一般的な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の並列処理
ここまでの実装で基本的な動きは実装できましたが、実はこのままでは複数のタスクを同時に削除しようとした場合に問題が起きてしまいます。
- この章の完成版ソースコード(concurrent-mutationsブランチ)
himorishige/remix-form-example at chapter/concurrent-mutations
削除時に該当のタスクが透過状態になるようスタイルを追加して問題を検証してみます。
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.state
やfetcher.submission
などtransition.state
、transition.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については下記ブログ記事がとても参考になります。
- この章の完成版ソースコード(optimistic-uiブランチ)
himorishige/remix-form-example at chapter/optimistic-ui
楽観的UIを実装することでユーザーが操作した内容は即座に反映される(見かけ上)ため、逆にローディング、待機時のUIは不要となってきます。
また、今回は楽観的UIの挙動と削除時のエラーを再現するためにactionFunction
のdelete
条件分岐の中で意図的に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にはForm
やuseTransition
、useFetcher
だけでもまだまだ多くの機能を持っています。そのほとんどがWeb APIに沿った実装を取っているので、公式ドキュメントだけでなくMDNも参考にすることでRemixアプリケーションの実装を進めていくことができるのは大きなメリットとなります。
また、Remixを使ってみることでWeb標準の技術(Web API)のより深い機能を知るきっかけにもなるかもしれません。
公式ドキュメントや公式YouTubeチャンネルでも詳しく解説されているのでぜひそちらも参考にRemixの世界を体験してみてください。