基本的には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 dev
でlocalhostに接続出来れば問題無いと思います。
Root Route
設定次第で変える事はできそうですが、デフォルトではroot.tsx
がいわゆるレイアウトとしてレンダリングされ、子要素は別途ルーティングの結果がレンダリングされるようです。
root.tsx
のexport default
に指定している関数がレイアウトとしてレンダリングされています。
Meta
やLinks
といったコンポーネントが入っていますが、そのうち説明があると想定してとりあえず無視して進めます。
スタイルシートの設定の一例として、JavaScript modulesとしてインポートする方法が記載されています。
import type { LinksFunction } from "@remix-run/node";
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"));
}
fakeContacts
はdata.ts
内で定義される仮想データベースを模擬したオブジェクトとい言えるでしょう。data.ts
内でデータがハードコードされています。
実際にはloader
内で外部にfetchするなどの処理を書くのだと思います。
型を適応するにはuseLoaderData
にloader
の型を適応させます。
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アプリケーションであれば、useState
やuseEffect
などで変更を察知して必要な箇所を再レンダリングするする必要がありそうですが、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}`);
};
フォームデータのシリアライズは実際に作成する際には色々気をつける箇所もあるかもしれませんが、非常にシンプルに書けていると思います。
上記のformData
はformData.get([フォームの名称])
でデータにアクセスできるそうで、
<input
defaultValue={contact.first}
aria-label="First name"
name="first"
type="text"
placeholder="First"
/>
のように書かれているフォームのname
がフォームの名称にあたります。
とはいえ今回はフォームの名称をupdateContact
に必要な名称に合わせてあるため、Object.fromEntries(formData)
で全部拾ってきて渡している実装になっています。
root.tsx
のaction
ではloader
と同じjson({contact})
を返していましたが、ここではリダイレクト処理が含まれるレスポンスを返すそうです。
また、NavLink
を利用すると、to
のプロパティに指定されているURLをレンダリングしてる際に適応されるclassなど適応できるようです。
この辺は嬉しい仕様。
Global Pending UI
Remixでは次のページがロード完了するまで古いページを表示しているような仕様になってるそうで。
遷移時の挙動をカスタマイズするにはuseNavigation
を利用してRemixの状態を利用します。
const navigation = useNavigation()
navigation
がidle
、loading
、submitting
の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ボタンが押された後に
- action でリダイレクトした後、Remixは画面上のデータの loader をすべて呼び出して最新の値を取得する。 useLoaderData は新しい値を返し、コンポーネントを更新させる。
という処理が成されるようです。
Index Route
Root Routeの箇所でもふれましたが、いわゆるindex.tsx
にあたるものです。
app/routes/_index.tsx
が該当するようですが、名称には_
が必要なようです。やや特殊なルーティングのせいでしょうか?
データの変更の箇所にある「データの更新」で作成したapp/routes/contacts.$contactId_.edit.tsx
にあるCancel
と記載されたボタンの実装です。
import {
Form,
useLoaderData,
useNavigate,
} from "@remix-run/react";
export default function EditContact() {
const { contact } = useLoaderData<typeof loader>();
const navigate = useNavigate();
return (
<Form key={contact.id} id="contact-form" method="post">
{}
<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";
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 });
};
この変更をすることで、フォームに文字を入力して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"
/>
{}
</Form>
これでクエリが適応されたURLを開いた際にはloader
からq
が渡され、フォームフィールドに適切なテキストが入力されている状態になるはずです。
次に1個目の問題に対応していきます。 ReactのuseEffect
を利用して挙動を実装するようです。
import { useEffect } from "react";
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]);
}
useState
などを利用して状態管理する方法もあるようですが、現状だと上記のような実装がシンプルということでしょうか。
この辺は適宜使い分けるのがよさそうです。
フォームの内容が変更されればURLが更新されるようにするには、useSubmit
を利用して実装するようです。
hookで簡単に実装できるのでええかんじです。
検索時のSpinnerの追加
このデモではわざと遅延を設定しているようですが、本番環境でもレスポンス待ちなどほとんどのケースで遅延は考えられます。
その際のスピナー(いわゆるぐるぐる)をつけてUXをマシにしようというヤツです。
root.tsx
で検索中を検知する変数を定義します。
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"
);
}
何も待ちなどがない場合はnavigation.location
がundefined
だそうですが、遷移中はデータが格納されるそうです。
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
に実装していきます。
export default function App() {
return (
<html lang="en">
{}
<body>
<div id="sidebar">
{}
<div>
<Form
id="search-form"
onChange={(event) => {
const isFirstSearch = q === null;
submit(event.currentTarget, {
replace: !isFirstSearch,
});
}}
role="search"
>
{}
</Form>
{}
</div>
{}
</div>
{}
</body>
</html>
);
}
useSubmit
で生成したsubmit
の第二引数のオブジェクトにreplace
というキーとともにbooleanを渡せば履歴スタックの変更か、履歴スタックに追加かが選べるようです。
URL変更なしのフォーム
上記まではフォームの変更や送信でURLが変更されていましたが、変更せずにフォーム送信したいケースもあるはずです。
チュートリアルの場合ではお気に入りボタン?である★マークの挙動などが該当します。
この辺を実装するのにuseFetecher
を利用するようです。
app/routes/contacts.$contactId.tsx
に変更を加えていきます。
import {
Form,
useFetcher,
useLoaderData,
} from "@remix-run/react";
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";
import { getContact, updateContact } from "../data";
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",
});
};
Form
で呼び出されたaction
のような挙動が実装できました。
ちゃんとサイドバーと表示されている人物の名前の横にある星の両方が変更されています。
Optimistic UI
上記のお気に入りボタンの反映にも遅延が入っているようです。
実際の挙動もここまでではなさそうですが、サーバーとの通信がある以上遅延はあるでしょう。
この遅延に対してもなにがしかのUIの実装が望ましいですが、Spinnerのところではnavigation
を利用していました。今回はURLの遷移がないのでこれは利用できません。
そこでfetcher.state
で実装できるようなのですが、ここではOptimistic Updates(楽観的更新)を採用して実装してみようという試みのようです。
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は自動的に適応されるようです。
ややクセはありそうですが、慣れれば非常にシンプルでかつ効果的な実装になっているのではないでしょうか。
こまかい制御をやろうとすると原始的な実装やドキュメントを読み込む必要が出てくるかもしれませんが、学習コストとしては思ったより低いのではないでしょうか。