参考
Part2に続き、Remixのチュートリアルを走ってみます。
URLSearchParams と GET メソッドによる送信
これまでのインタラクティブUIはすべて下記のいずれかでした。
- URLを変更するリンクか
- formデータをaction関数にポストする
import type {
LinksFunction,
+ LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports & exports
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
+ const url = new URL(request.url);
+ const q = url.searchParams.get("q");
+ const contacts = await getContacts(q);
return json({ contacts });
};
コードの解説
検索フィールドは、URLを変更するリンクとデータをポストするフォームの両方の特性を持っています。ユーザーが検索フィールドに名前(例えば "ryan")を入力してEnterキーを押すと、ブラウザのURLにそのクエリが URLSearchParams の形式で追加されます
(例: http://localhost:3000/?q=ryan)。
FormDataのシリアライズ:
このフォームは
loader関数によるフィルタリング:
関数はリクエストから検索パラメータにアクセスできます。この例では、URLから検索クエリ(q)を取得し、それを使用して連絡先リストをフィルタリングします。
GETリクエストの特性
この操作はGETリクエストなので、POSTリクエストとは異なり、Remixは action 関数を呼び出しません。GETフォームの送信は、リンクをクリックするのと同じであり、URLだけが変更されます。
URLをフォーム状態に同期する
ここには、すぐに対処できる UX の問題がいくつかあります。
- 検索後に [戻る] をクリックすると、リストのフィルタリングが解除されても、フォーム フィールドには入力した値が残ります。
- 検索後にページを更新すると、リストがフィルターされていても、フォーム フィールドに値が含まれなくなります。
言い換えれば、URL と入力の状態が同期していません。
URLからの値を入力してみましょう。
URLと入力を同期する。
// existing imports & exports
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const contacts = await getContacts(q);
+ return json({ contacts, q });
};
export default function App() {
+ const { contacts, q } = useLoaderData<typeof loader>();
const navigation = useNavigation();
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form id="search-form" role="search">
<input
aria-label="Search contacts"
+ defaultValue={q || ""}
id="q"
name="q"
placeholder="Search"
type="search"
/>
{/* existing elements */}
inputのdefaultValueにqを入れるようにしたため、検索後にページを更新しても、入力フィールドにクエリが表示されます。
URLのParamsとInputの値を同期する
// existing imports
+ import { useEffect } from "react";
// existing imports & exports
export default function App() {
const { contacts, q } = useLoaderData<typeof loader>();
const navigation = useNavigation();
+ useEffect(() => {
+ const searchField = document.getElementById("q");
+ if (searchField instanceof HTMLInputElement) {
+ searchField.value = q || "";
+ }
+ }, [q]);
// existing code
入力のたびに、Listを更新する処理の追加
+ useSubmit,
} from "@remix-run/react";
// existing imports & exports
export default function App() {
+ const submit = useSubmit();
// existing code
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form
id="search-form"
+ onChange={(event) =>
+ submit(event.currentTarget)
+ }
role="search"
>
{/* existing elements */}
</Form>
useSubmitフックの使用
useSubmitフックがRemixによって提供されています。
このフックを使ってフォームを自動的に送信する機能を実装します。
フォームの自動送信
FormのonChangeでsubmit関数を呼び出すことで、文字が入力されるたびに、Submitされる処理となります。
検索へスピナーの追加
useNavigation
を使用して、ユーザーが検索を実行しているかどうかを判断する変数searching
を作成します。
これは、navigation.location
がundefined以外かつ、searchパラメータに "q"(検索クエリ)が存在するかをチェックします。
つまり、navigation.locationがpendingの状態かつ、検索中の場合searchingになります。
ページを読み込み後はmnavigation.locationがundefinedになるので、qに値を持っていてもfalseになります。
// existing imports & exports
export default function App() {
+ const searching =
+ navigation.location &&
+ new URLSearchParams(navigation.location.search).has(
+ "q"
+ );
// existing code
<input
aria-label="Search contacts"
+ className={searching ? "loading" : ""}
defaultValue={q || ""}
id="q"
name="q"
placeholder="Search"
type="search"
/>
<div
aria-hidden
+ hidden={!searching}
id="search-spinner"
/>
<div
className={
+ navigation.state === "loading" && !searching
? "loading"
: ""
}
id="detail"
>
<Outlet />
</div>
}
ブラウザの履歴を制御する。
現状では、1文字ごとにページ移動したことになってしまうため、これを修正する。
初期の検索かどうか、を q === null で判断し、最初の検索以外では、submitのreplaceにtrueを与えることで、現在のブラウザの履歴エントリを置き換えるよう指示しています。
<Form
id="search-form"
+ onChange={(event) => {
+ const isFirstSearch = q === null;
+ submit(event.currentTarget, {
+ replace: !isFirstSearch,
+ });
+ }}
role="search"
>
ページ遷移なしでフォーム送信を実装する
これまでは、ページ遷移するようになっていたが、ページ遷移なしで実装したいパターンもある。
下記の2つを実装して、ページ遷移なしのフォーム送信を実装します。
FormをFetcherに変更する。
<Form>
をuseFetcher
に置き換えます。
actionを作成する。
これまでのチュートリアル通り、データを更新するactionを追加します。
// existing imports
import {
Form,
useFetcher,
useLoaderData,
} from "@remix-run/react";
import type {
ActionFunctionArgs,
+ LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports & exports
+ import { getContact, updateContact } from "../data";
// existing imports
+ export const action = async ({
+ params,
+ request,
+}: ActionFunctionArgs) => {
+ invariant(params.contactId, "Missing contactId param");
+ const formData = await request.formData();
+ return updateContact(params.contactId, {
+ favorite: formData.get("favorite") === "true",
+ });
+};
// existing code
const Favorite: FunctionComponent<{
contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
+ const fetcher = useFetcher();
const favorite = contact.favorite;
return (
+ <fetcher.Form method="post">
<button
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
name="favorite"
value={favorite ? "false" : "true"}
>
{favorite ? "★" : "☆"}
</button>
+ </fetcher.Form>
);
};
コードの変更自体は、<fetcher.Form>
へ置き換えるだけです。
<fetcher.Form>
を使用すると、アクションの実行後にページのデータが自動的に再検証されます。
これにより、ユーザーインターフェース上での変更がリアルタイムに反映され、エラーも同じように処理されます。
サンプルでは、管理画面でお気に入りに追加すると、サイドバーも自動で更新され星が表示されます。
Optimistic UI (楽観的UI)を実装する
Optimistic UIを実装する(サーバーの反応を待たずに、画面を変更すること)
contact:favorite
// existing imports
const Favorite: FunctionComponent<{
contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
const fetcher = useFetcher();
+ const favorite = fetcher.formData
+ ? fetcher.formData.get("favorite") === "true"
+ : contact.favorite;
フォームデータの確認
最初に fetcher.formData
が存在するかどうかをチェックします。
これは、フォームが送信された後に設定されます。 ページを開いたタイミングではundefinedです。
fetcher.formData ?
オプティミスティックなUI更新
もし fetcher.formData
が存在する場合、それはユーザーが最後にフォームに入力した値を表します。
この値に基づいて、お気に入りの状態(favorite)を即座に更新します。
これにより、サーバーからの応答がまだないにもかかわらず、UIはユーザーの操作を反映します。
fetcher.formData.get("favorite") === "true"
失敗時のUI戻し
ネットワークリクエストが失敗すると、fetcher.formData
はリセットされます。
その結果、条件式はcontact.favorite
(元の読み込んだデータの状態)に戻ります。
これにより、UIはサーバーの実際のデータ(リクエスト前の状態)を反映するようになります。
+ const favorite = fetcher.formData
+ ? fetcher.formData.get("favorite") === "true"
+ : contact.favorite;