1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RuruCun個人開発Advent Calendar 2023

Day 6

Remixのチュートリアルで入門してみる Part 3

Posted at

参考

Part2に続き、Remixのチュートリアルを走ってみます。

URLSearchParams と GET メソッドによる送信

これまでのインタラクティブUIはすべて下記のいずれかでした。

  • URLを変更するリンクか
  • formデータをaction関数にポストする
app/root.tsx
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のシリアライズ:
このフォームは

ではないため、Remixはブラウザのように動作し、FormDataをリクエストボディの代わりにURLSearchParamsにシリアライズします。

loader関数によるフィルタリング:
関数はリクエストから検索パラメータにアクセスできます。この例では、URLから検索クエリ(q)を取得し、それを使用して連絡先リストをフィルタリングします。

GETリクエストの特性
この操作はGETリクエストなので、POSTリクエストとは異なり、Remixは action 関数を呼び出しません。GETフォームの送信は、リンクをクリックするのと同じであり、URLだけが変更されます。

URLをフォーム状態に同期する

ここには、すぐに対処できる UX の問題がいくつかあります。

  1. 検索後に [戻る] をクリックすると、リストのフィルタリングが解除されても、フォーム フィールドには入力した値が残ります。
  2. 検索後にページを更新すると、リストがフィルターされていても、フォーム フィールドに値が含まれなくなります。
    言い換えれば、URL と入力の状態が同期していません。

URLからの値を入力してみましょう。

URLと入力を同期する。

app/root.tsx
// 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の値を同期する

app/root.tsx
// 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を更新する処理の追加

app/root.tsx
+ 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になります。

app/root.tsx
// 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文字ごとにページ移動したことになってしまうため、これを修正する。

image.png

初期の検索かどうか、を 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を追加します。

contacts.$contactId.tsx
// 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>を使用すると、アクションの実行後にページのデータが自動的に再検証されます。
これにより、ユーザーインターフェース上での変更がリアルタイムに反映され、エラーも同じように処理されます。

サンプルでは、管理画面でお気に入りに追加すると、サイドバーも自動で更新され星が表示されます。

image.png

Optimistic UI (楽観的UI)を実装する

Optimistic UIを実装する(サーバーの反応を待たずに、画面を変更すること)

contact:favorite

contacts.$contactId.tsx
// 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;
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?