白猫のメモ帳

C#とかJavaとかJavaScriptとかHTMLとか機械学習とか。

Remixのチュートリアルをやってみる

こんばんは。

App Routerが出るくらいの頃からNext.jsをちらほら使っていたのですが、今回はRemixのチュートリアルをやります。
Next.jsがちょっと重たいなと思うのと(処理がではなくフレームワークの規模的に)、微妙に融通が効かないと思うことがあるので、もう少しシンプルなフロントエンドフレームワークを触ってみようかなという思惑です。

Remixとは

RemixはReactベースのフルスタックWebフレームワークです。
主にSSRですが、最近SPAモードにも対応したとかなんとか。
基本方針としてWeb標準をかなり重視しているらしいです。

今回のチュートリアル

Tutorial(30m)だそうです。ほんとに30分で終わるんでしょうか。
ちなみにチュートリアルの中身を全部書いても仕方がないので、ところどころ省略します。詳しく見たい場合は必ずオリジナルのページを参照してください。
その代わりに感想やらなにやら書いていこうと思います。
remix.run

プロジェクトを作ります

まずはnpxコマンドでチュートリアル用のプロジェクトを作ります。

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

基本Enter押していけばいいと思いますが、せっかくなのでプロジェクト名は変えました。

 remix   v2.9.2 💿 Let's build a better website...

   dir   Where should we create your new project?
         ./remix-tutorial

      ◼  Template: Using remix-run/remix/templates/remix-tutorial...
      ✔  Template copied

   git   Initialize a new git repository?
         Yes

  deps   Install dependencies with npm?
         Yes

      ✔  Dependencies installed

      ✔  Git initialized

  done   That's it!

         Enter your project directory using cd .\remix-tutorial
         Check out README.md for development and deploy instructions.

         Join the community at https://rmx.as/discord

できるファイルは結構シンプルですね。

作ったフォルダに潜ってとりあえずサーバーを立ち上げます。

cd remix-tutorial
npm run dev

http://localhost:5173/ にアクセスすると、Remixのデフォルトページが表示されます。
はい。チュートリアルのとおりですね。

tsxファイル1つしかないので、とりあえずroot.tsxを覗いてみます。こんな感じ。

import {
  Form,
  Links,
  Meta,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />  <!-- 子ページからmetaの情報が差し込まれる? -->
        <Links />  <!-- ここにCSSが入ってくるのかな? -->
      </head>
      <body>
        <!-- 長いので省略 -->
        <ScrollRestoration /> <!-- これはReactのスクロール復元するやつ -->
        <Scripts />  <!-- ここにReactの処理が入ってくるのかな -->
      </body>
    </html>
  );
}

スタイルシートの追加

これを足してみよとのこと。CSSが読み込まれてなかったのでシンプルな画面だったんですね。
このlinksがコンポーネントに収まるみたいです。

cssのうしろの?urlはなんなんだろうと思って外してみたら、CSSがstyleタグでインラインに展開されました…。
つけておいたほうが良さそうですね。

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

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

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

ということはtitleとかのmetaタグはこうですね。(寄り道しがち)

import type { LinksFunction, MetaFunction } from "@remix-run/node";

export const meta: MetaFunction = () => [
  { title: "Remix Contacts" },
  { description: "A contacts app built with Remix" },
];

おしゃれになりました。が、この段階ではリンクを押しても404です。

ルーティングの追加

リンク先は http://localhost:5173/contacts/1http://localhost:5173/contacts/2 です。
これらのルーティングを足していきます。

まずは「app/routes/contacts.$contactId.tsx」ファイルを作ります。
$は変数なのでNext.jsでいうところの[]にあたりそうです。routes/の下がパスになるってことですね。
「.」はパスでは「/」になる…ということはフォルダは使わないでファイルをひたすら平らに並べるのかな。

ファイルの中身はチュートリアルページからコピペします。

import { Form } from "@remix-run/react";
import type { FunctionComponent } from "react";

import type { ContactRecord } from "../data";

export default function Contact() {
  const contact = {
    first: "Your",
    last: "Name",
    avatar: "https://placekitten.com/200/200",
    twitter: "your_handle",
    notes: "Some notes",
    favorite: true,
  };

  return (
    <div id="contact">
      <div>

// 長いので省略

これでリンク先が表示されるようになりました。(中身ないけど)

アウトレットの追加

多分ショッピングモール的なあれではないですよね。
見た感じroot.tsxがNext.jsのlayout.tsxにあたるもので、Outletは{children}の変わりみたいな感じですね。

// existing imports
import {
  Form,
  Links,
+  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

// existing imports & code

export default function App() {
  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <div id="sidebar">{/* other elements */}</div>
+        <div id="detail">
+          <Outlet />
+        </div>
        {/* other elements */}
      </body>
    </html>
  );
}

アバター画像のURLが404なのは何故なのでしょう。

クライアントサイドルーティング

SSRは初回レンダリングをサーバサイドで行いますが、以降のページ遷移はクライアントサイドで行うことができます。
aタグを使うとリクエストがサーバに飛んでしまうので、Linkコンポーネントを使います。

ややこしい話なんですが、SSRは文字通りサーバサイドでのレンダリングなので、普通に作るとMPAになります。
SSRで生成したページからクライアントサイドに続きを任せるのは本来はユニバーサルレンダリングとか言われるものです。
ただ、最近はNext.jsやNuxt.jsでも当たり前にできているので、SSRにこの意味が内包されているような気もします。

// existing imports
import {
  Form,
  Links,
+  Link,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

// existing imports & code

export default function App() {
  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <div id="sidebar">
          {/* other elements */}
          <nav>
            <ul>
              <li>
-                <a href={`/contacts/1`}>Your Name</a> 
+                <Link to={`/contacts/1`}>Your Name</Link>
              </li>
              <li>
-               <a href={`/contacts/2`}>Your Friend</a>
+               <Link to={`/contacts/2`}>Your Friend</Link>
              </li>
            </ul>
          </nav>
        </div>
        {/* other elements */}
      </body>
    </html>
  );
}

データ取得

サーバサイドでデータ取得をします。ちょっと変更箇所が多いので、追加分だけ。
チュートリアルでは一旦コンパイルエラーにしてから修正してますがまとめてやってしまいます。

jsonはRemix用にJSON化してくれる関数です。
getContactsはdata.tsにあるデータ取得関数です。

import { json } from "@remix-run/node";
import { getContacts } from "./data";

loaderが実際のデータ取得の関数です。
名前は固定みたいですね。名前変えたらエラーになりました。

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

loader関数を使ってデータを取得します。
loaderじゃなくてtypeof loaderなのは、loader関数の型が欲しいからですね。

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

読み込んだデータからコンテンツを作るところ。普通にJSXですね。

{contacts.length ? (
   <ul>
      {contacts.map((contact) => (
      <li key={contact.id}>
         <Link to={`contacts/${contact.id}`}>
            {contact.first || contact.last ? (
            <>
               {contact.first} {contact.last}
            </>
            ) : (
            <i>No Name</i>
            )}{" "}
            {contact.favorite ? (
            <span></span>
            ) : null}
         </Link>
      </li>
      ))}
   </ul>
) : (
   <p>
      <i>No contacts</i>
   </p>
)}

パラメタを受け取ってリンク先を表示できるようにする

ここも一旦コンパイルエラーにしていますが、まとめてやってしまいます。
$contactIdを変数として受け取れるので、これを使ってデータ取得します。

tiny-invariantはエラーチェック用のライブラリです。

import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";
import { getContact } from "../data";

loader関数の中でパラメタを受け取ります。
contactのチェックをしないとContactRecord | nullになってしまうので、404エラーに落とします。

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

いい感じです。

404エラーは凄くシンプル…。

データの追加

ここまでで参照系ができたので、次は更新系です。
root.tsxにloaderと同じように今度はaction関数を追加します。これも名前は固定ですね。
Remixがシンプルにformのsubmit時にこの処理を呼んでくれるようです。
(というよりはPOSTのときに呼ばれるだけで、自身のパスにPOSTしてるからってことかな…)

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

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

これで一応Newボタンは押せるようになりましたが、空っぽのデータです。

データの更新

更新画面を作るために新しいファイル「app/routes/contacts.$contactId_.edit.tsx」を足します。ファイルの中身は長いので省略。

ここで「_」がつくのが何故なのかややこしいですが、「_」がないと「contacts.$contactId.tsx」をレイアウトとみなしたネストしたページとみなされてしまうようです。
基本的にはURLをネストしたらレイアウトもネストされるという考え方みたいですね。
正直この辺りルーティングはかなり命名規則で縛ってる感じなので、ちゃんと理解しないとつらそうです…。

Editボタンで編集画面に行けるようになりましたが、これはformのaction="edit"だからってことですよね。
となりのDeleteボタンはaction="destroy"だけどこっちは画面遷移しなそうだけどどうするんでしょう。
export defaultなしでactionだけ定義するのかな。(たぶん後でわかると思う)

<Form action="edit">
  <button type="submit">Edit</button>
</Form>

更新系の処理も追加すると更新できるようになりました。
チュートリアルではチュートリアルを書いてくれている人を登録していますが、髪型似合ってると思います。

Fill out the form, hit save, and you should see something like this! (Except easier on the eyes and maybe less hairy.)
フォームに記入して保存すると、次のような画面が表示されます。(目に優しく、毛が少ないことを除いては)

更新処理をもうちょっとよく見てみます。
Object.fromEntriesでformDataをオブジェクトに変換、最後にリダイレクトして更新した人のページに飛ばしているようです。
requestがWeb標準のRequestオブジェクトなのが素敵です。

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

そして新規登録時に更新画面へ最初からリダイレクトするようにしておきます。

// existing imports
import { json, redirect } from "@remix-run/node";
// existing imports

export const action = async () => {
  const contact = await createEmptyContact();
  return redirect(`/contacts/${contact.id}/edit`);
};

// existing code

見た目の調整

選択中のリンクの色変えと、ローディング中に半透明にする処理をroot.tsxに追加します。
NavLinkコンポーネントはアクティブ状態とペンディング状態を操作し、useNavigation()は現在のページの状態を取得します。
(だんだん変更箇所増えてきてつらいので、変更の際はimport省略します)

export default function App() {

  const { contacts } = useLoaderData<typeof loader>();
+  const navigation = useNavigation();

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <ul>
            {contacts.map((contact) => (
              <li key={contact.id}>
+                <NavLink
+                  className={({ isActive, isPending }) =>
+                    isActive
+                      ? "active"
+                      : isPending
+                      ? "pending"
+                      : ""
+                  }
+                  to={`contacts/${contact.id}`}
+                >
                  {/* existing elements */}
+                </NavLink>
              </li>
            ))}
          </ul>
          {/* existing elements */}
        </div>
        <div 
+          className={
+            navigation.state === "loading" ? "loading" : ""
+          }
          id="detail">
          <Outlet />
        </div>
      </body>
    </html>
  );
}

色変えはわかりやすいですが、ローディングはよくわからないですね。
data.tsのgetContactで遅延を入れてみると半透明になるのがわかりました。

export async function getContact(id: string) {
+  await new Promise((resolve) => setTimeout(resolve, 1000));
  return fakeContacts.get(id);
}


データの削除

更新と同様に新しいファイル「app/routes/contacts.$contactId_.destroy.tsx」を足します。
中身はこんな感じです。想定通りactionだけ定義するとAPI的に使えるということですね。
(何故にdeleteではなくdestroyなんでしょうか)

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

import { deleteContact } from "../data";

export const action = async ({
  params,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  await deleteContact(params.contactId);
  return redirect("/");
};

ルート画面の賑やかし

ルート画面のメインコンテンツが何もないのが寂しいのでコンテンツを追加するらしいです。
これはつまり/にアクセスするとOutletコンポーネントが空の状態だからです。

「app/routes/_index.tsx」を追加すると、/にアクセスしたときに表示されるようになります。
「_index.tsx」もまた特別な名前ですね。このチュートリアルでは出てこないですが、前につくアンダースコアはパスを隠すような使い方をするらしいです。
例えば「/hoge」は「/app/routes/_fuga.hoge.tsx」を呼び出し、レイアウトに「/app/routes/_fuga.tsx」に使える感じです。

export default function Index() {
  return (
    <p id="index-page">
      This is a demo for Remix.
      <br />
      Check out{" "}
      <a href="https://remix.run">the docs at remix.run</a>.
    </p>
  );
}


キャンセルボタン

編集画面のキャンセルボタンが何も起こらないので処理を追加します。
「app/routes/contacts.$contactId_.edit.tsx」を編集します。
navigate(-1)は前の画面に戻る処理です。historyAPIをシンプルに使えるのはいいですね。

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

検索

検索フォームが最初からあるのですが、入力しても何も起きません。
「app/routes/_root.tsx」を編集します。
ここも無理にラップされずにRequestのurlからURLオブジェクトを作ってとWeb標準っぽくてよい感じです。

+export const loader = async ({
+  request,
+}: LoaderFunctionArgs) => {
+  const url = new URL(request.url);
+  const q = url.searchParams.get("q");
+  const contacts = await getContacts(q);
-export const loader = async () => {
-  const contacts = await getContacts();
  return json({ contacts });
};


URLと検索フォームの同期

検索後にページを更新すると、検索フォームが空ですが、リストがフィルタリングされている状態になります。
直前でloaderがパラメタアクセスするようにしているので、検索フォームの値をURLに反映させることで解決します。

一方で検索後に「戻る」をクリックすると、URLが変わってフィルタリングされなくなりますが、検索フォームの入力が残ったままになります。
これにはReactのuseEffectを使って検索フォームの値をURLに反映させることで解決します。
(document.getElementByIdで直接値を取っていますが、useStateを使うのもいいねともこっそり書いてありますね)

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 });
+  return json({ contacts, q });
};

export default function App() {
-  const { contacts } = useLoaderData<typeof loader>();
+  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

+  useEffect(() => {
+    const searchField = document.getElementById("q");
+    if (searchField instanceof HTMLInputElement) {
+      searchField.value = q || "";
+    }
+  }, [q]);

  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 */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

入力中のリアルタイム検索

現在は検索フォーム上でEnterを押すとリストが更新されますが、リアルタイムで検索したいです。
onChangeイベントを使って検索フォームの値が変更されるたびにフォームを送信するようにします。

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

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <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={true}
+                hidden={!searching}
                id="search-spinner"
              />
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        <div 
          className={
-            navigation.state === "loading"
+            navigation.state === "loading" && !searching
              ? "loading"
              : ""
          }
          id="detail">
          <Outlet />
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

ちなみに連打されるので、こういうのキャッシュをちゃんとしないとすごいことになりますよね。
jsonの引数にheaderの設定ができるので、ここに「Cache-Control」を入れるとキャッシュもできそう。

return json(
    { contact }, 
    { headers: { "Cache-Control": "public, max-age=3600" } 
});


検索履歴の管理

useEffectを使って戻るができるようになりましたが、1文字入力するごとにURLが変わるので戻っても1文字分しか戻れなくなってしまいました。
submitする際にreplaceを使うとhistoryにpushではなくreplaceされるので、戻るができるようになります。

<Form
   id="search-form"
-   onChange={(event) => 
-      submit(event.currentTarget)
-   }
+   onChange={(event) => {
+      const isFirstSearch = q === null;
+      submit(event.currentTarget, {
+         replace: !isFirstSearch,
+      });
+   }}
   role="search"
>

ナビゲーションしないform

これまではすべてURLが変更されるformを扱ってきましたが、ナビゲーションせずにデータを送信したい場合もあります。
この場合にはuseFetcher()を使うと良いらしいです。「app/routes/contacts.$contactId.tsx」を編集します。
ただ、この場合は「contacts.$contactId.tsx」のアクションがこの機能に使われてしまうのでちょっと気になりますね。
別の「contacts.$contactId_.fav.tsx」とか作ってもいいような気もします。

+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",
+  });
+};

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
+  const fetcher = useFetcher();
  const favorite = contact.favorite;

  return (
-    <Form method="post">
+    <fetcher.Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "★" : "☆"}
      </button>
-    </Form>
+    </fetcher.Form>
  );
};

楽観的なUI(Optimistic UI)

星マークをクリックしてから反映されるまでに少し時間がかかります。
fetcher.formDataはpost時点でそのデータを知っているので、参照すれば即座に反映できます。
(formを送信すると再レンダリングがかかるのか、fetcher.formDataを参照してるから再レンダリングかかかるのか…はて)
ちなみにここで反映されるのは詳細の方で、リストの★はactionのupdateContactで反映されます。

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const fetcher = useFetcher();
-  const favorite = contact.favorite;
+  const favorite = fetcher.formData
+    ? fetcher.formData.get("favorite") === "true"
+    : contact.favorite;

// existing code


おしまい

チュートリアル終了です。ずいぶん長くなってしまいました。
脇道に逸れてドキュメント読んでいたり、アウトプットもしていたので全然30分じゃ終わりません。
「ふんふん」って思いながらコピペして動作確認してだけやってれば30分くらいかもですね。
でもせっかくやるなら絶対手を動かしたほうがいいと思います。

なんとなくベースの部分はわかった気がするし、書いてる感じもわりとしっくりくる感じだったので次はなにか作ってみようかなと思います。
それでは。