技術メモ

プログラミングとか電子工作とか

Remix入門を一通りやった感想的なアレ

基本的にはNext.jsを学習してきましたが、13あたりのアップデートからややついて行けない感が出て新規に採用するべきか悩んでいました。
とりあえずことある毎に公式のチュートリアルは全て見てはいるんですが、きっちり理解できている感じがしなくて「なんとなく」使ってる感が強いのです。
(React Server Componentsの理解もイマイチです)
また、Amplifyへの対応もある程度はされていますがVercelにロックインされていってるような仕様にもやや抵抗感がないとも言えません。

そこで、下記のスライドをちら見したときにまさしく自分に当てはまることかな?と思いました。

こういうときは素直にオススメを学んでみるべきだと考えていますので、Remixあたりを学習してみます。

他人が読んで分かりやすいような入門記事では無いと思います、申し訳御座いません。

セットアップ

テンプレートから生成する場合は--templateオプションをつけてnpxで生成するようです。

npx create-remix@latest --template remix-run/remix/templates/remix-tutorial

ディレクトリ名などはCUIのウィザードで指定できるので適当に指定します。
とりあえずnpm run devlocalhostに接続出来れば問題無いと思います。

Root Route

設定次第で変える事はできそうですが、デフォルトではroot.tsxがいわゆるレイアウトとしてレンダリングされ、子要素は別途ルーティングの結果がレンダリングされるようです。
root.tsxexport defaultに指定している関数がレイアウトとしてレンダリングされています。
MetaLinksといったコンポーネントが入っていますが、そのうち説明があると想定してとりあえず無視して進めます。

スタイルシートの設定

スタイルシートの設定の一例として、JavaScript modulesとしてインポートする方法が記載されています。

import type { LinksFunction } from "@remix-run/node";
// existing imports

import appStylesHref from "./app.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];

NextJSなどと比べるとやや異なりますが、許容範囲内でしょう。
別のコンポーネントなどでも思ったのですが、Remixは利用するコンポーネントに対して特定の関数名でexport constしておけば設定が適応されるような設計になっていそうです。

ルーティング

レイアウトはapp/route.tsxでしたが、肝心のページはapp/routes/にファイルを追加していけばルーティングされてレンダリングしてくれるようです。
チュートリアルではlocalhost:3000/contacts/123のようなファイルをapp/routes/contacts.$contactId.tsxのように指定するようです。
ファイル名に.を指定すると、ルーティングでは/と同義になるようで、$contactIdのように$マークを利用するとレンダリングする際のパラメータとして受けれるようです。 (どうも、routesディレクトリ以下にディレクトリを配置しないような思想?まだイマイチつかめてません。)

root.tsx<Outlet/>に、該当するファイルがレンダリングされて挿入されます。
いわゆるindexに関してはIndex Routesという項目があるので後々語られるのでしょう。

クライアントサイドでレンダリングするようなリンクは、Next.js同様Linkというコンポーネントで作成するようです。

import {
  Link
} from "@remix-run/react";

データのロード

データのフェッチなどはuseLoaderDataという関数を利用していくようです。

import { json } from "@remix-run/node";
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

~

import { getContacts } from "./data";

export const loader = async () => {
  const contacts = await getContacts();
  return json({ contacts });
};

export default function App() {
  const { contacts } = useLoaderData();

getContactsは下記の様に定義されています。

export async function getContacts(query?: string | null) {
  await new Promise((resolve) => setTimeout(resolve, 500));
  let contacts = await fakeContacts.getAll();
  if (query) {
    contacts = matchSorter(contacts, query, {
      keys: ["first", "last"],
    });
  }
  return contacts.sort(sortBy("last", "createdAt"));
}

fakeContactsdata.ts内で定義される仮想データベースを模擬したオブジェクトとい言えるでしょう。data.ts内でデータがハードコードされています。
実際にはloader内で外部にfetchするなどの処理を書くのだと思います。

型を適応するにはuseLoaderDataloaderの型を適応させます。

const {contact} = useLoaderData<typeof loader>()

この辺はシンプルで良い感じだと思います。

ローダーでのURLパラメータ

ルーティングの箇所で少し言及しましたが、app/routes/contacts.$contactId.tsx$contactId部分は、loarderでパラメータとして利用できるようです。

export const loader = async ({ params }) => {
  const contact = await getContact(params.contactId);
  return json({ contact });
};

ここでは型の適応とバリデーションがなされていないのですが、Remixに含まれる型とtiny-invariantを利用して下記の様に書けるそうです。

import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";

~

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ contact });
};

invariantはファイル名とコード間でパラメータ名が間違えてないか確認してくれる関数のようです。基本的には書きましょうという感じでしょうか。
throw new Response周りもnullな際に404をレンダリングしてくれるそうです。この辺はloaderの基本フォーマットみたいな感じで使えそうです。

データ変更

データの新規作成

Remixではクライアントサイドのルーティングを使用してaction関数にフォームのデータを送信するそうです。
とりあえず例を見ていきます。

import { createEmptyContact, getContacts } from "./data";

export const action = async () => {
  const contact = await createEmptyContact();
  return json({ contact });
};

通常のReactアプリケーションであれば、useStateuseEffectなどで変更を察知して必要な箇所を再レンダリングするする必要がありそうですが、Remixの場合はactionに書けばエエ感じに処理されるみたいです。基本的には全ての操作がフォームとして処理されるんでしょうか?

データの更新

ここで編集用のページを作成していきます。
app/routes/contacts.$contactId_.edit.tsxというファイル名で作成するようですが、どうもapp/routes/contacts.$contactId.edit.tsx.として作成するとapp/routes/contacts.$contactId.tsx.の内部にルーティングの箇所で出てきた<Outlet/>レンダリングされるようなものになるようです。この辺はやや命名規則がヤヤコシイ気がします。
ファイル内容はチュートリアルの内容を参照して下さい。

actionが書かれていないので、その当たりを追記していきます。

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  await updateContact(params.contactId, updates);
  return redirect(`/contacts/${params.contactId}`);
};

フォームデータのシリアライズは実際に作成する際には色々気をつける箇所もあるかもしれませんが、非常にシンプルに書けていると思います。
上記のformDataformData.get([フォームの名称])でデータにアクセスできるそうで、

        <input
          defaultValue={contact.first}
          aria-label="First name"
          name="first"
          type="text"
          placeholder="First"
        />

のように書かれているフォームのnameがフォームの名称にあたります。
とはいえ今回はフォームの名称をupdateContactに必要な名称に合わせてあるため、Object.fromEntries(formData)で全部拾ってきて渡している実装になっています。

root.tsxactionではloaderと同じjson({contact})を返していましたが、ここではリダイレクト処理が含まれるレスポンスを返すそうです。

また、NavLinkを利用すると、toのプロパティに指定されているURLをレンダリングしてる際に適応されるclassなど適応できるようです。
この辺は嬉しい仕様。

Global Pending UI

Remixでは次のページがロード完了するまで古いページを表示しているような仕様になってるそうで。
遷移時の挙動をカスタマイズするにはuseNavigationを利用してRemixの状態を利用します。

const navigation = useNavigation()

navigationidleloadingsubmittingの3種を返してくれるそうです。

データの削除

これはサンプルのコードに既に記載済みでした。

          <Form
            action="destroy"
            method="post"
            onSubmit={(event) => {
              const response = confirm(
                "Please confirm you want to delete this record."
              );
              if (!response) {
                event.preventDefault();
              }
            }}
          >

どうやら、Formタグにactionという属性を付与すると、[自分のファイル名].destroy.tsxのように属性名のファイルに記載されたファイルを参照し、そこのaction関数を実行するようです。
詳しい挙動はチュートリアルに記載がありますが、Submitボタンが押された後に

  1. は、ブラウザのデフォルトの動作であるサーバーへの新しいドキュメントの POST リクエストを防ぎ、代わりにクライアントサイドのルーティングと fetch を使ってリクエストをエミュレートする。
  2. は "contacts.$contactId.destroy" の新しいルートにマッチし、リクエストを送信する。
  3. action でリダイレクトした後、Remixは画面上のデータの loader をすべて呼び出して最新の値を取得する。 useLoaderData は新しい値を返し、コンポーネントを更新させる。

という処理が成されるようです。

Index Route

Root Routeの箇所でもふれましたが、いわゆるindex.tsxにあたるものです。
app/routes/_index.tsx が該当するようですが、名称には_が必要なようです。やや特殊なルーティングのせいでしょうか?

Cancel Button

データの変更の箇所にある「データの更新」で作成したapp/routes/contacts.$contactId_.edit.tsx にあるCancelと記載されたボタンの実装です。

// existing imports
import {
  Form,
  useLoaderData,
  useNavigate,
} from "@remix-run/react";
// existing imports & exports

export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();
  const navigate = useNavigate();

  return (
    <Form key={contact.id} id="contact-form" method="post">
      {/* existing elements */}
      <p>
        <button type="submit">Save</button>
        <button onClick={() => navigate(-1)} type="button">
          Cancel
        </button>
      </p>
    </Form>
  );
}

useNavigateというhookを利用して実装しています。非常にシンプルな実装で利用しやすい工夫が成されているようです。

URLSearchParams と GET サブミッション

サイドバーにある検索ボックスの実装です。
今まではフォームの機能としてactionを利用していましたが、検索ボックスの挙動としては、URLの変更が、下記の様なクエリの変更となるケースが考えられます。

http://localhost:5173/?q=ryan

<Form method="post">はデータの追加や更新に利用されるアクションなので、そうでない場合の実装を考えて行くようです。
まずはクエリを含むURLがリクエストされた際の実装をloaderにしていきます。

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 });
};

// existing code

この変更をすることで、フォームに文字を入力してEnterをおすか、クエリを含むURLにアクセスすればサイドバーがフィルタリングされて表示されます。

URLの状態とフォームの状態の同期

現在の実装だと下記の二点の問題があります。

  • 検索後に戻るボタンをクリックすると、リストがフィルタリングされていないのにフォームフィールドにまだ入力した値が残っている。
  • 検索後にページを更新すると、リストはフィルタリングされているのにフォームフィールドの値がなくなっている。

上記はクエリが適応されたURL(http://localhost:5173/?q=ryanのようなURL)とフォームフィールド(検索ボックス)の状態が同期していないことによります。
これに対し、まずは下記から対処していきます。

~

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 });
};

~

  const { contacts, q } = useLoaderData<typeof loader>();

~

            <Form id="search-form" role="search">
              <input
                aria-label="Search contacts"
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              {/* existing elements */}
            </Form>

これでクエリが適応されたURLを開いた際にはloaderからqが渡され、フォームフィールドに適切なテキストが入力されている状態になるはずです。

次に1個目の問題に対応していきます。 ReactのuseEffectを利用して挙動を実装するようです。

// 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
}

useStateなどを利用して状態管理する方法もあるようですが、現状だと上記のような実装がシンプルということでしょうか。
この辺は適宜使い分けるのがよさそうです。

FormのonChangeによる送信

フォームの内容が変更されればURLが更新されるようにするには、useSubmitを利用して実装するようです。
hookで簡単に実装できるのでええかんじです。

検索時のSpinnerの追加

このデモではわざと遅延を設定しているようですが、本番環境でもレスポンス待ちなどほとんどのケースで遅延は考えられます。
その際のスピナー(いわゆるぐるぐる)をつけてUXをマシにしようというヤツです。
root.tsxで検索中を検知する変数を定義します。

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const submit = useSubmit();
  const searching =
    navigation.location &&
    new URLSearchParams(navigation.location.search).has(
      "q"
    );

  // existing code
}

何も待ちなどがない場合はnavigation.locationundefinedだそうですが、遷移中はデータが格納されるそうです。
navigation.location.searchを見てSpinnerを実装していきます。

            <Form
              id="search-form"
              onChange={(event) =>
                submit(event.currentTarget)
              }
              role="search"
            >
              <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"
              />
            </Form>

検索中かどうかはSpinnerで実装されたので、<Outlet/>がフェードアウトしないように変更します。

      <div
        className={
          navigation.state === "loading" && !searching ? "loading" : ""
        }
        id="detail"
      >

履歴スタックの管理

検索文字を変更する度にURL遷移が発生し、履歴スタックが無限に増えて行きます。
これを避ける為に現在のエントリーを変更する処理をroot.tsxに実装していきます。

// existing imports & exports

export default function App() {
  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form
              id="search-form"
              onChange={(event) => {
                const isFirstSearch = q === null;
                submit(event.currentTarget, {
                  replace: !isFirstSearch,
                });
              }}
              role="search"
            >
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

useSubmitで生成したsubmitの第二引数のオブジェクトにreplaceというキーとともにbooleanを渡せば履歴スタックの変更か、履歴スタックに追加かが選べるようです。

URL変更なしのフォーム

上記まではフォームの変更や送信でURLが変更されていましたが、変更せずにフォーム送信したいケースもあるはずです。
チュートリアルの場合ではお気に入りボタン?である★マークの挙動などが該当します。
この辺を実装するのにuseFetecherを利用するようです。
app/routes/contacts.$contactId.tsxに変更を加えていきます。

// existing imports
import {
  Form,
  useFetcher,
  useLoaderData,
} from "@remix-run/react";
// existing imports & exports

// 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>
  );
};

上記の実装でFavoriteのFormがactionを呼び出します。
なので、actionを実装していきます。

import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports

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

Formで呼び出されたactionのような挙動が実装できました。
ちゃんとサイドバーと表示されている人物の名前の横にある星の両方が変更されています。

Optimistic UI

上記のお気に入りボタンの反映にも遅延が入っているようです。
実際の挙動もここまでではなさそうですが、サーバーとの通信がある以上遅延はあるでしょう。
この遅延に対してもなにがしかのUIの実装が望ましいですが、Spinnerのところではnavigationを利用していました。今回はURLの遷移がないのでこれは利用できません。
そこでfetcher.stateで実装できるようなのですが、ここではOptimistic Updates(楽観的更新)を採用して実装してみようという試みのようです。

// existing code

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const fetcher = useFetcher();
  const favorite = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : 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やらnavigationを採用していれば、Optimistic UIは自動的に適応されるようです。

チュートリアルを通してのかんそう

ややクセはありそうですが、慣れれば非常にシンプルでかつ効果的な実装になっているのではないでしょうか。
こまかい制御をやろうとすると原始的な実装やドキュメントを読み込む必要が出てくるかもしれませんが、学習コストとしては思ったより低いのではないでしょうか。