🕌

ReactフレームワークのRemixチュートリアル解説!

2024/01/30に公開
2

はじめに

React のフレームワークは Next.js をよくいじっているのですが
Remix が最近 v2.5 がリリースされ SPA モードがでて
これはなかなかあついんじゃないかということで
触ったことがなかった Remix を
チュートリアルに従って解説していきます!

環境構築

まずは環境構築です。
チュートリアルにあるテンプレートを使用して
環境構築を行います。

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

セットアップ完了後app/root.tsxのファイルを見てください
Nextjs の AppRouter を触ったことがある人なら
ピンとくると思いますが。
root.tsxがHTMLの雛形部分になります。

日本人なので日本語にしておきましょう。

app/root.tsx
export default function App() {
  return (
-    <html lang="en">
+    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
	  // existing code

スタイルシートの追加

Remix チュートリアルでは予め css を書いてくれています。
css を読み込むコードを記述します。

app/root.tsx
+ import type { LinksFunction } from "@remix-run/node";
import {
  Form,
  Links,
  LiveReload,
  Meta,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
+ import appStylesHref from "./app.css";

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

export default function App() {
  return (

// existing code

見慣れないコードがでてきたと思います。
解説していきます。

import appStylesHref from "./app.css";

こちらはみて分かる通り appStylesHref という名前で
app.cssを読み込みますということです。

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

こちらは links という関数で Remix が指定した名前を入れています。
links以外はだめです。

こちらの links は返り値として配列を返しており
第一引数にrel,第二引数はhrefとしてありますが、

なにをしているかというと、
この links で追加したものは
先程のapp/root.tsx内の<Links/>コンポーネント内に入ってきます。

app/root.tsx
export const links: LinksFunction = () => [
	//ここで指定した配列たちが
  { rel: "stylesheet", href: appStylesHref },
];

export default function App() {
  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />

		↓ここに入ってくる。
        <Links />
      </head>
      <body>

	  // existing code

今回でいうとLinks内にこれが入ります。

<link rel="stylesheet" href="app.css" />

export const meta とすることで
meta データを入れることもできます。

Path の作成と子要素

Nextjs と同じく Remix では
ファイルシステムルーティングを使用してます。

Remix は app の配下に/routesディレクトリを作成し
その中にファイルを作成することにより Path を生成することができます。

チュートリアルでは
routes/contacts.$contactId.tsxファイルを作成します。

Nextjs ではディレクトリをどんどん作成することにより
Path を生成していたと思いますが、
Remix では.を繋げることにより Path を生成します。
例を紹介します。

Nextjs の場合

app/dashboard/customers/page.tsx
https://localhost3000/dashboard/customers

Remix の場合

app/routes/dashboard.customers.tsx
https://localhost3000/dashboard/customers

Nextjs ではルーティングによりディレクトリ階層が
どんどん深くなっていきますが、
Remix では1列で完結できますということです。

公式ページよりcontacts.$contactId.tsx
ファイルの中をコピペしてください。

contacts.$contactId.tsx
import { Form } from "@remix-run/react";
import type { FunctionComponent } from "react";

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

export default function Contact() {
  const contact = {

// existing code

色々コードがありますが
実際 URL を打ち込んでみても
何も表示されません。

Remix にはNested Routesという仕様があります。

Nextjs では子要素は children としてコンポーネントを使用しますが
Remix ではoutletというコンポーネントを使用します。

root.tsx
import type { LinksFunction } from "@remix-run/node";
import {
  Form,
  Links,
  LiveReload,
  Meta,
+  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

// existing code

+	    <div id="detail">
+          <Outlet />
+        </div>
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

先程いった通りOutletの中に子コンポーネントが入ってきます。

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

root.tsx内のサイドバー部分が a タグで作成されています。
Nextjs と同じでaタグで作成するとページ全体がリロードされていまいます。
Link コンポーネントを作成することにより
クライアントサイドだけのルーティングを使用することができます。

Nextjs との違いは
Path を指定する際は href ではなく to を使用します。

<Link to={`/contacts/1`}>Your Name</Link>

データを取得

app 直下にある data.ts からデータを取得します。
追加されたコードを解説していきます。

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

remix-run のメソッドで json()とすることで
中に指定したオブジェクトをシリアライズ化しています。

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

こちらは links と同じく Remix が指定している loader 関数です。
この中で非同期処理でgetContactsとして
data.ts の中を処理しています。

const { contacts } = useLoaderData();

こちらも remix-run のメソッドで先程の contsxts 関数を
データとして読み込んでいます。

先程の loader 関数はサーバー側でのみ行われて
こちらの useLoaderData()はクライアントで行われます。

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

最後のこちらは contacts がもしあれば
map 関数を用いて表示させて、
データが無ければ No contacts を表示させています。

contacts の型指定

現在 contacts に型がなくエラーが表示されています。
useLoaderData のジェネリクスを使用して型を指定します。

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

loader 関数の返り値を取って typeof として指定しています。

サイドバーの URL パラメーター

現在サイドバーの名前をクリックしたら
URL は変わっていますが、中身変わらない状態なので
これを動的に変更していきます。

遣り方は先程と同じで remix-run からjson,useLoaderData,
getContactを使用してデータを取得します。

root.tsx で使用した際の違いは
loader で引数にparamsを指定していることです。

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

こちらの params は何を指定しているかというと
ルートの$contactIdを指定してます
今回でいうと URL 末のmichael-jackson等の
名前がここに入ってきます。

contact 関数の型を見ていただくと
ContactRecord | nullとなっています。
null の可能性があるためエラーが発生しています。
そのため null の可能性を消すコードを追加します。

contacts.$contactId.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";

// existing code
export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  return json({ contact });
};

  // existing code

公式チュートリアルではtiny-invariantという
プラグインを用いて
params.contactIdがない場合エラーを表示しています。

これで undefined がなくなり contactId が必ず string として入ってくるように
なったんですが、まだ null の可能性が残っています。
null の場合に 404 エラーを表示させることで解決します。
先程のコードに追加します

contacts.$contactId.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";

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

  // existing code

これで入ってきてほしくない数値の場合 404 エラーを出すことができます。

データの追加

新しく連絡先を追加するコードを root.tsx に追加します

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

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

この action 関数はlinksloaderと同じく Remix で
決められた関数で action を使う際に使用する関数です。
createEmptyContact ということで
空のコンタクトを作成するということです。

追加後左上にある New をクリックすると
No Name として追加されているのが確認できると思います。

ここで疑問に思うかと思います。
Next.js で行う際は onClick や useState などで
行うと思いますが、
Remix では action しか書いていないのになぜ UI に反映されているかと言うと

Remix の action,loader 等はサーバー側で行われる処理で
action が行われた際にもう一度 loader などが行われて
UI として表示されています。

データの更新

ドキュメントに沿って新しくルートを作成しましょう。
app/routes/contacts.$contactId_.edit.tsx
作成したファイルに_が付いているのに注目してください

_をつけることによりネストしなくなります。
_をつけないと contactId の中の UI として認識されてしまいます。

データを更新するためのコードで action を追加します。

contacts.$contactId_.edit.tsx
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}`);
};

引数の params は先程と同じくcontactIdを指しています。
request はformData()を取ってきているのですが
これは HTML の Form 要素と同じです。
Object.fromEntries()を使用することで
この FormData のオブジェクトを
バックエンドでも使いやすいオブジェクトに変換しています。
最後に更新させて、redirect しています。

新規レコード作成時編集ページにリダイレクト

root.tsx
+ import { json, redirect } from "@remix-run/node";

// existing code

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

// existing code

新しく連絡先を追加するときに
そのまま編集ページに飛ばしています。

アクティブリンクのスタイリング

どのリンクを選択しているかわかるづらいので
わかりやすくスタイリングしていきます。

root.tsx
// existing code

<li key={contact.id}>
+	<NavLink
+		className={({ isActive, isPending }) =>
+		isActive
+			? "active"
+			: isPending
+			? "pending"
+			: ""
+		}
+		to={`contacts/${contact.id}`}
+	>
+	</NavLink>
</li>

// existing code

NavLink を見ていただくと
className で文字列ではなく関数を受け取っています。
isActive だったらactiveクラスを付与して
isPending だったらpendingクラスを付与しています。

Loading を追加

ナビゲーション以外の部分にローディングを行う
コードを追加します。

root.tsx
  export default function App() {
  const { contacts } = useLoaderData<typeof loader>();
+ const navigation = useNavigation();

 // existing code

	<div
+	  className={navigation.state === "loading" ? "loading" : ""}
+	  id="detail"
	>
	  <Outlet />
	</div>

// existing code

アプリケーションの状態をuseNavigation()で取得しています。
loading の場合 className に loading を追加しているコードです。
検証ツールで低速3 G などにしてみると
ページが遷移した際に半透明になっているのがわかると思います。

レコードの削除

contacts.$contactId.destroy.tsxファイルを作成して
action を追加します。

contacts.$contactId.destroy.tsx
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("/");
};

loader などと同じく引数に params を持ち
invariant 関数で第一引数が true であれば
deleteContact を data.ts から呼び出し、
ホームにリダイレクトしています。

ホームに追加

削除したあとホームに戻るのですが
右側が何も表示されていなく寂しいので
Remix と表示させます。

ファイルとして routes 直下に_index.tsxを作成します。

_index は Remix において特殊なファイルで
その routes におけつトップページみたいなもので
それ以降のパスがなかったときに表示されるようになります。

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

キャンセルボタン

現在編集を行う際にキャンセルボタンを押しても
何も変わらないのでキャンセルできるようにします。

contacts.$contactId_.edit.tsx
// 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>
  );
}

button に onClick で-1 を指定しています。
これはブラウザの戻るボタンと同じになります。
useNavigate()を使うと簡単に扱えるようになりました。

検索機能

現在検索をしても URL は変化しますが画面はなにも
変わらない状態になっています。

loader 部分のコードを追加します。

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

// existing code

こちらはざっくり何をしているかと言うと

const url = new URL(request.url);

まず new URL として引数の URL オブジェクトを返します。

const q = url.searchParams.get("q");

実際に検索してもらうとわかるのですが、
URL の末尾に?q=◯◯と検索されたワードと?q=が表示されるはずです。
その値を取ってきています。

const contacts = await getContacts(q);
return json({ contacts });

最後にgetContacts()で検索されたワードを
contact を表示させています。

検索機能がこれだけでできるのすごいですよね!

URL フォームの同期

現在検索後ページを更新すると、検索の値が消えているにも関わらず
検索結果が残っている状態になっています。

さらに検索後に戻るボタンを押しても
検索が残っている状態になっていてます。
これら2つを直します。

まずは検索後のページ更新問題を解決します。
直し方は簡単で loader の中にqを返します。

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 */}
            </Form>
            {/* existing elements */}

先程行った通り loader でqを返しuseLoaderDataでも
qが使えるようになり、
検索する input の初期値で q または空文字を指定してます。
これで URL の末尾の検索部分を input に反映させている状態になるので
更新しても検索結果が残っています。

続いて戻るボタンでリセットさせる方法ですが
ドキュメントにも書いてありますが
useEffectを使うしかないよねーって言っています。

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
}

q が変更された時に
document.getElementById()で searchField をもってきて
searchField が HTMLInputElement のインスタンスを
継承していれば q または空文字を返す処理を行っています。

1 文字づつ検索

現在検索ワードを入力後エンターを押さないと
検索されないですが
onChange を使って文字を打つたびに更新を
行うことができます。

root.tsx
export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
+  const submit = useSubmit();

  // existing code

{/* existing elements */}

<Form
	id="search-form"
+	onChange={(event) =>
+		submit(event.currentTarget)
+	}
	role="search"
	>

{/* existing elements */}

event.currentTarget を行うことで
1 文字 1 文字、文字を入力するたびに検索を行ってくれます。

検索中かどうかわかりやすくする

検索中にスピナーを追加します。

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

   <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"
		/>
}
 {/* existing elements */}

まずは navigation の location を取ってきます
navigation.location.searchqを持っているか
どうかを判断してあれば className を付与して
ローディングアニメーションをもたせています。

検索履歴の管理

現状入力ちょっと時間おいて入力して、という
行為を繰り返すと戻るボタンを押したとき
何度も戻るボタンを押さないとホームに
戻れない現象になっています。

それを解決する為に Form の onChange 部分を変更します。

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

何をしているかというと
最初に q が null だったら isFirstSearch を定義します。
次に submit の第2引数に replace を使用しています。
こちらは履歴など追加せずにページ遷移を行う場合に
使用したりするのですが、
Remix では submit でも使用できます。
要は2回目以降は全て replace にしましょうということです。

Form が無いときの Navigation

Form がいらない時、または URL を変更したくない場合は
useFetcher()を使用します。

contacts.$contactId.tsx
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>
  );
};

こうすることによりナビゲーションを行わずに html にフェッチ
されるだけになります。
action が必要なので追加します。

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

お気に入りの追加削除を表しています。
お気に入りの追加削除で URL が変更したり
再度フェッチされるのはされるのはやりたくないので
fetcher.Formを使用します

Optimistic UI

お気に入りボタンを押した際に若干
遅れて星が変わると思います。

SNS の X などではクリックした際に
すぐアイコンが変わったりしますが、
その機能が Optimistic UI です。

本来はボタンを押したタイミングでサーバーへリクエストが飛んで
サーバーからレスポンスが返ってきて無事に成功したら UI を変更します。
みたいな順番なのですが、
ボタンを押したタイミングで UI だけ変更しちゃいましょう
というのが Optimistic UI です。

こうすることによりサーバーに送る前の段階で
UI を変更することができます。

contacts.$contactId.tsx
// existing code

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;

  return (

// existing code

本来バックエンドに favorite があるかないか判断して
action が成功したら UI を変更していましたが、
formDataにfavoriteがある場合はtrueにして
後で favorite があるかないか判断して action をするコードです。

レンダリング回数は1回増えてしまいますが、
UI としてこちらの方が優れています。

おわりに

Remix チュートリアルはこちらで終わりです!
routes の設定や@remix-run など
初めてで少々難しかったと思いますが
Nextjs は Vercel にデプロイすることで
大きな恩恵を得られますが、
Vercel が使えない環境などでは
Remix を採用してみるのもいいかもしれませんね!

最後までお読みいただきありがとうございました。

Discussion

asip2k25asip2k25

Reactライブラリの Remix → Reactフレームワークの Remix
のほうがよい気がします。

じゅんじゅん

ありがとうございます!
本当にその通りです!
修正いたしました。