ReactフレームワークのRemixチュートリアル解説!
はじめに
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の雛形部分
になります。
日本人なので日本語にしておきましょう。
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 を読み込むコードを記述します。
+ 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/>コンポーネント内に入ってきます。
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
Remix の場合
app/routes/dashboard.customers.tsx
Nextjs ではルーティングによりディレクトリ階層が
どんどん深くなっていきますが、
Remix では1列で完結できますということです。
公式ページより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
というコンポーネントを使用します。
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
を指定していることです。
export const loader = async ({ params }) => {
const contact = await getContact(params.contactId);
return json({ contact });
};
こちらの params は何を指定しているかというと
ルートの$contactId
を指定してます
今回でいうと URL 末のmichael-jackson
等の
名前がここに入ってきます。
contact 関数の型を見ていただくと
ContactRecord | null
となっています。
null の可能性があるためエラーが発生しています。
そのため null の可能性を消すコードを追加します。
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 エラーを表示させることで解決します。
先程のコードに追加します
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 に追加します
import { createEmptyContact, getContacts } from "./data";
export const action = async () => {
const contact = await createEmptyContact();
return json({ contact });
};
この action 関数はlinks
、loader
と同じく 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 を追加します。
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 しています。
新規レコード作成時編集ページにリダイレクト
+ 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
新しく連絡先を追加するときに
そのまま編集ページに飛ばしています。
アクティブリンクのスタイリング
どのリンクを選択しているかわかるづらいので
わかりやすくスタイリングしていきます。
// 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 を追加
ナビゲーション以外の部分にローディングを行う
コードを追加します。
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 を追加します。
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 におけつトップページみたいなもので
それ以降のパスがなかったときに表示されるようになります。
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>
);
}
キャンセルボタン
現在編集を行う際にキャンセルボタンを押しても
何も変わらないのでキャンセルできるようにします。
// 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 部分のコードを追加します。
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
を返します。
// 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
を使うしかないよねーって言っています。
// 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 を使って文字を打つたびに更新を
行うことができます。
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 文字、文字を入力するたびに検索を行ってくれます。
検索中かどうかわかりやすくする
検索中にスピナーを追加します。
// 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.search
がq
を持っているか
どうかを判断してあれば className を付与して
ローディングアニメーションをもたせています。
検索履歴の管理
現状入力ちょっと時間おいて入力して、という
行為を繰り返すと戻るボタンを押したとき
何度も戻るボタンを押さないとホームに
戻れない現象になっています。
それを解決する為に Form の onChange 部分を変更します。
<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()
を使用します。
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 が必要なので追加します。
// 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 を変更することができます。
// 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
Reactライブラリの Remix → Reactフレームワークの Remix
のほうがよい気がします。
ありがとうございます!
本当にその通りです!
修正いたしました。