Remixの理解を深める!
フロントエンドのフレームワークであり、エッジランタイムへのデプロイをサポートしている。
2021年末頃から、Remixは開発者コミュニティに広く受け入れられ、CFWorkersとの統合によって、
低遅延でリッチなインタラクティブなWebアプリを構築し、エッジにデプロイできるようになった。
SPA Mode
-
Remix
がv2.5
でSPAモード
をリリース。SPAモード
を使ったらサーバーサイドでのJS環境が不要でブラウザ側にすべてを任せる形になる。(Next.js
とかRemix
が出る前の従来のReactはブラウザ側でのみ動かすライブラリだった)SPA
だとindex.html
のみでいろんなページを構成してた。ユーザーがリクエストを送ったらサーバーがやることはindex.html
とindex.js
を返すだけで、あとはブラウザ側でindex.html
とJSによって頑張ってレンダリングする(サーバーから新たなhtml
が送られることはない)というのがSPA
。 - でも
SPA
を使えるだけじゃなくてSPAモード
を使ったらloader
action
みたいな機能(それ自体はサーバーサイドで動かせるため使えないけれど)としてclientLoader
clientAction
という機能が一緒に使える。つまりRemix
のSPAモード
を使うことによってRemix
の書き方を踏襲しつつSPA
で作ることができるし、将来的にRSCなどReactのフル機能を使うとなったら簡単に移行できる。 - 例えば、管理画面は
SEO
OGイメージ
など大事ではないためSPA
で十分だしサーバーサイドの機能を使うのは持て余す。だから一回認証とったらあとはブラウザのみでいろいろ完結させた方が早いし良い。ただReactのフル機能を引き出すためにはサーバー側も必要のため、ケースバイケース。 - また今後の
Remix
の計画にはCloudflare
の開発がしやすくなるCloudflare Dev Proxy
なるものがある。それによってWorkers
D1
などのCloudflare
の各種機能をローカル環境で再現できる為Remix
とCloudflare
を使った開発がやりやすくなる。Next.js x Vercel
に対しRemix x Cloudflare
も結構流行りそう。
app/root.jsx is what we call the "Root Route". It's the root layout of your entire app. Here's the basic set of elements you'll need for any project:
-
app/root.jsx
がアプリ全体のルートレイアウト。Link
がCSSを読み込んでてMeta
がメタデータを読み込んでてScripts
に最終的にビルドしたJSが入る。LiveReload
は開発時に自分のコードを直接開発サーバーに反映させる。
import {
Links,
Meta,
Outlet,
Scripts,
} from "@remix-run/react";
export default function App() {
return (
<html>
<head>
<link
rel="icon"
href="data:image/x-icon;base64,AA"
/>
<Meta />
<Links />
</head>
<body>
<h1>Hello world!</h1>
<Outlet />
<Scripts />
</body>
</html>
);
}
Create the app/routes directory and contact route module
mkdir app/routes
touch app/routes/contacts.\$contactId.tsx
-
Remix
はapp/routes
というディレクトリを作って
その中に_index.tsx
about.tsx
concerts.trending.tsx
など作る。
ファイル名をカンマによって区切ると階層構造にできる。
Nested Routes and Outlets
Since Remix is built on top of React Router, it supports nested routing. In order for child routes to render inside of parent layouts, we need to render an Outlet in the parent. Let's fix it, open up app/root.tsx and render an outlet inside.
// existing imports
import {
Form,
Links,
LiveReload,
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>
);
}
-
Remix
はReact Router
をベースとする為、ネストされたルーティング(入れ子になったルート)をサポートする。これは、親のレイアウト内に子のルートをレンダリングするために必要となる。子のルートを親のレイアウトの中でレンダリングするためには、親のコンポーネント内でOutlet
という特別なコンポーネントをレンダリングする。
Client Side Routing
You may or may not have noticed, but when we click the links in the sidebar, the browser is doing a full document request for the next URL instead of client side routing.
Client side routing allows our app to update the URL without requesting another document from the server. Instead, the app can immediately render new UI. Let's make it happen with <Link>.
Change the sidebar <a href> to <Link to>
// existing imports
import {
Form,
Link,
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
// existing imports & exports
export default function App() {
return (
<html lang="en">
{/* other elements */}
<body>
<div id="sidebar">
{/* other elements */}
<nav>
<ul>
<li>
<Link to={`/contacts/1`}>Your Name</Link>
</li>
<li>
<Link to={`/contacts/2`}>Your Friend</Link>
</li>
</ul>
</nav>
</div>
{/* other elements */}
</body>
</html>
);
}
- サイドバーの
<a href>
を<Link to>
に変更する。これにより、サーバーへの新しいドキュメントリクエストを行う代わりに、クライアントサイドでページの内容を動的に更新できる。
There are two APIs we'll be using to load data, loader and useLoaderData. First we'll create and export a loader function in the root route and then render the data.
Export a loader function from app/root.tsx and render the data
You may have noticed TypeScript complaining about the contact type inside the map. We can add a quick annotation to get type inference about our data with typeof loader:
// existing imports
import { json } from "@remix-run/node";
import {
Form,
Link,
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
// existing imports
import { getContacts } from "./data";
// existing exports
export const loader = async () => {
const contacts = await getContacts();
return json({ contacts });
};
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
return (
<html lang="en">
{/* other elements */}
<body>
<div id="sidebar">
{/* other elements */}
<nav>
{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>
)}
</nav>
</div>
{/* other elements */}
</body>
</html>
);
}
- データをロードするために使用するAPIは
loader
とuseLoaderData
の2つがある。
loader
はサーバーサイドでデータを読み込むために使用され、そのあとフロントエンドでuseLoaderData
を通じて取得されたデータをレンダリングする。また<typeof loader>
のように簡単に型定義できる。
Remember the
These params are passed to the loader with keys that match the dynamic segment. For example, our segment is named $contactId so the value will be passed as params.contactId.
These params are most often used to find a record by ID. Let's try it out.
Add a loader function to the contact page and access data with useLoaderData
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
// existing imports
import { getContact } from "../data";
export const loader = async ({ params }) => {
const contact = await getContact(params.contactId);
return json({ contact });
};
export default function Contact() {
const { contact } = useLoaderData<typeof loader>();
// existing code
}
// existing code
-
app/routes/contacts.$contactId.tsx
のファイル名にある$contactId
は動的セグメントで、URLのその位置にある動的な値に一致する。これらの値を「URLパラメータ」又は「パラメータ」と呼ぶ。これらのパラメータは、動的セグメントに一致するキーを持ってローダーに渡される。$contactId
なら値はparams.contactId
として渡される。これらのパラメータは、IDによるrecordの検索によく使われる。loader
を追加しuseLoaderData
でデータにアクセスできる。
First problem this highlights is we might have gotten the param's name wrong between the file name and the code (maybe you changed the name of the file!). Invariant is a handy function for throwing an error with a custom message when you anticipated a potential issue with your code.
Next, the useLoaderData<typeof loader>() now knows that we got a contact or null (maybe there is no contact with that ID). This potential null is cumbersome for our component code and the TS errors are flying around still.
We could account for the possibility of the contact being not found in component code, but the webby thing to do is send a proper 404. We can do that in the loader and solve all of our problems at once.
import type { LoaderFunctionArgs } from "@remix-run/node";
// existing imports
import invariant from "tiny-invariant";
// existing imports
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
-
invariant
によってparams.contactId
がなかったらエラーメッセージをスローできる。
それによってstringのidが入ることを保証できるけれどもcontact
がなかったら404
とする。
Creating Contacts
We'll create new contacts by exporting an action function in our root route. When the user clicks the "new" button, the form will POST to the root route action.
Export an action function from app/root.tsx
// existing imports
import { createEmptyContact, getContacts } from "./data";
export const action = async () => {
const contact = await createEmptyContact();
return json({ contact });
};
// existing code
The createEmptyContact method just creates an empty contact with no name or data or anything. But it does still create a record, promise!
Wait a sec ... How did the sidebar update? Where did we call the action function? Where's the code to re-fetch the data? Where are useState, onSubmit and useEffect?!
This is where the "old school web" programming model shows up. <Form> prevents the browser from sending the request to the server and sends it to your route's action function instead with fetch.
In web semantics, a POST usually means some data is changing. By convention, Remix uses this as a hint to automatically revalidate the data on the page after the action finishes.
In fact, since it's all just HTML and HTTP, you could disable JavaScript and the whole thing will still work. Instead of Remix serializing the form and making a fetch request to your server, the browser will serialize the form and make a document request. From there Remix will render the page server side and send it down. It's the same UI in the end either way.
We'll keep JavaScript around though because we're going to make a better user experience than spinning favicons and static documents.
-
root.tsx
によってaction
関数をエクスポートする。すると「新規」ボタンをクリックしたときフォームはroot
のaction
に POST される。<Form>
はブラウザがサーバーにリクエストを送信するのを防ぎ、その代わりにfetch
を使用してroot
のaction
関数にリクエストを送信する。Web セマンティクス
では、POST は通常、一部のデータが変更されていることを意味する。 慣例的にRemix
はこれをヒントとして使用しaction
の終了後にページ上のデータを自動的に再検証する。 だから先述のuseLoaderData
などによってUIが自動更新される。Remix
のいいところ!
Updating Data
Let's add a way to fill the information for our new record.
Just like creating data, you update data with <Form>. Let's make a new route at app/routes/contacts.$contactId_.edit.tsx.
Create the edit component
touch app/routes/contacts.$contactId_.edit.tsx
Copy code to clipboard
Note the weird _ in
Add the edit page UI
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";
import { getContact } from "../data";
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 });
};
export default function EditContact() {
const { contact } = useLoaderData<typeof loader>();
return (
<Form key={contact.id} id="contact-form" method="post">
<p>
<span>Name</span>
<input
defaultValue={contact.first}
aria-label="First name"
name="first"
type="text"
placeholder="First"
/>
<input
aria-label="Last name"
defaultValue={contact.last}
name="last"
placeholder="Last"
type="text"
/>
</p>
<label>
<span>Twitter</span>
<input
defaultValue={contact.twitter}
name="twitter"
placeholder="@jack"
type="text"
/>
</label>
<label>
<span>Avatar URL</span>
<input
aria-label="Avatar URL"
defaultValue={contact.avatar}
name="avatar"
placeholder="https://example.com/avatar.jpg"
type="text"
/>
</label>
<label>
<span>Notes</span>
<textarea
defaultValue={contact.notes}
name="notes"
rows={6}
/>
</label>
<p>
<button type="submit">Save</button>
<button type="button">Cancel</button>
</p>
</Form>
);
}
- 通常
Remix
はファイル名が同じ接頭辞を持つルートが自動的にネストされる。たとえば、contacts.$contactId.tsx
とcontacts.$contactId.edit.tsx
という2つのルートがある場合は、後者は前者の中にネストされる。しかし、ファイル名の末尾にアンダースコア _
を追加すると、このデフォルトの挙動を無効にし、ネストされないようにできる。つまり$contactId_
を使うとapp/routes/contacts.$contactId.tsx
の中にネストされず独立したルートとして機能する。
Active Link Styling
Now that we have a bunch of records, it's not clear which one we're looking at in the sidebar. We can use NavLink to fix this.
Replace <Link> with <NavLink> in the sidebar
Note that we are passing a function to className. When the user is at the URL that matches <NavLink to>, then isActive will be true. When it's about to be active (the data is still loading) then isPending will be true. This allows us to easily indicate where the user is and also provide immediate feedback when links are clicked but data needs to be loaded.
// existing imports
import {
Form,
Links,
LiveReload,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
// existing imports and exports
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
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>
{/* existing elements */}
</body>
</html>
);
}
-
Active Link Styling(アクティブリンクのスタイリング)
多くのレコードがあるとサイドバーでどのレコードを見ているかはっきりしない。これを修正するためにNavLink
を使用できる。サイドバーの<Link>
を<NavLink>
に置き換える。className
に関数を渡していることに着目。ユーザーが<NavLink to>
に一致するURLにいる場合isActive
はtrue
になる。データのロード中であれば(アクティブになろうとしている場合)isPending
はtrue
になる。これによりユーザーがどこにいるかを簡単に示し、リンクがクリックされデータのロードが必要な場合に即時フィードバックを提供できる。
Global Pending UI
As the user navigates the app, Remix will leave the old page up as data is loading for the next page. You may have noticed the app feels a little unresponsive as you click between the list. Let's provide the user with some feedback so the app doesn't feel unresponsive.
Remix is managing all the state behind the scenes and reveals the pieces you need to build dynamic web apps. In this case, we'll use the useNavigation hook.
Use useNavigation to add global pending UI
// existing imports
import {
Form,
Links,
LiveReload,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
useNavigation,
} from "@remix-run/react";
// existing imports & exports
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
const navigation = useNavigation();
return (
<html lang="en">
{/* existing elements */}
<body>
{/* existing elements */}
<div
className={
navigation.state === "loading" ? "loading" : ""
}
id="detail"
>
<Outlet />
</div>
{/* existing elements */}
</body>
</html>
);
}
useNavigation returns the current navigation state: it can be one of "idle", "loading" or "submitting".
In our case, we add a "loading" class to the main part of the app if we're not idle. The CSS then adds a nice fade after a short delay (to avoid flickering the UI for fast loads). You could do anything you want though, like show a spinner or loading bar across the top.
- Global Pending UI(グローバルなペンディング UI)。ユーザーがアプリを操作する際に
Remix
は次のページのデータがロードされるまで古いページを表示したまま。リスト間をクリックする際にアプリが少し反応しないように感じるかも。アプリが反応しないように感じないために、ユーザーにフィードバックを提供する。Remix
は裏側ですべての状態を管理しダイナミックなウェブアプリを構築するために必要な部分を提示する。このケースではuseNavigation
フックを使用します。useNavigation
を使用して、グローバルなペンディング UI を追加する。useNavigation
は現在のナビゲーション状態を返す。これは"idle"
"loading"
"submitting"
のいずれかになる。この場合、アイドル状態でない場合はアプリのメイン部分に"loading"
クラスを追加する。CSSはそのあと低遅延でフェードを追加する。スピナーやローディングバーを表示するなど何でも行える。
Deleting Records
If we review code in the contact route, we can find the delete button looks like this:
<Form
action="destroy"
method="post"
onSubmit={(event) => {
const response = confirm(
"Please confirm you want to delete this record."
);
if (!response) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
Note the action points to "destroy". Like <Link to>, <Form action> can take a relative value. Since the form is rendered in contacts.
At this point you should know everything you need to know to make the delete button work. Maybe give it a shot before moving on? You'll need:
A new route
An action at that route
deleteContact from app/data.ts
redirect to somewhere after
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("/");
};
Alright, navigate to a record and click the "Delete" button. It works!
I'm still confused why this all works
When the user clicks the submit button:
<Form> prevents the default browser behavior of sending a new document POST request to the server, but instead emulates the browser by creating a POST request with client side routing and fetch
The <Form action="destroy"> matches the new route at "contacts.$contactId.destroy" and sends it the request
After the action redirects, Remix calls all the loaders for the data on the page to get the latest values (this is "revalidation"). useLoaderData returns new values and causes the components to update!
Add a Form, add an action, Remix does the rest.
-
action
はdestroy
を指す。<Link to>
のように、<Form action>
は相対的な値を取れる。このフォームがcontacts.$contactId.tsx
でレンダリングされるためdestroy
という相対アクションをクリックするとcontacts.$contactId.destroy
にフォームを送信する。送信ボタンをクリックすると<Form>
は、サーバーへ新しいドキュメントのPOST
リクエストを送信するというブラウザのデフォルトの動作を防ぎ、代わりにクライアントサイドのルーティングとfetch
を使用してPOST
リクエストを作成し、ブラウザをエミュレートする。<Form action="destroy">
はcontacts.$contactId.destroy
と一致しリクエストを送信する。action
がリダイレクトされた後Remix
はページ上のデータのすべてのローダーを呼び出し、最新の値を取得する。useLoaderData
は新しい値を返し、コンポーネントを更新する。
Index Routes
When we load up the app, you'll notice a big blank page on the right side of our list.
When a route has children, and you're at the parent route's path, the <Outlet> has nothing to render because no children match. You can think of index routes as the default child route to fill in that space.
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>
);
}
The route name _index is special. It tells Remix to match and render this route when the user is at the parent route's exact path, so there are no other child routes to render in the <Outlet />.
Voilà! No more blank space. It's common to put dashboards, stats, feeds, etc. at index routes. They can participate in data loading as well.
- 子ルートがあったら親ルートのパスにいるとき
<Outlet>
には何もレンダリングするものがない。なぜなら、どの子も一致しないため。_index
ルートを、そのスペースを埋めるデフォルトの子ルートと考える。_index
というルートは特別。これは、ユーザーが親ルートの正確なパスにいる場合に、このルートが一致してレンダリングされることをRemix
に指示する。
Cancel Button
On the edit page we've got a cancel button that doesn't do anything yet. We'd like it to do the same thing as the browser's back button.
We'll need a click handler on the button as well as useNavigate.
Add the cancel button click handler with useNavigate
// 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>
);
}
Now when the user clicks "Cancel", they'll be sent back one entry in the browser's history.
Why is there no event.preventDefault() on the button?
A <button type="button">, while seemingly redundant, is the HTML way of preventing a button from submitting its form.
Two more features to go. We're on the home stretch!
- キャンセルボタンはクリックハンドラと
useNavigate
が必要。useNavigate
でキャンセルボタンのクリックハンドラを追加する。これでユーザーが「キャンセル」をクリックすると、ブラウザの履歴の一つ前に戻る。なぜボタンにevent.preventDefault()
がないのか?<button type="button">
は、一見冗長に見えるが、HTML
でフォームを送信するボタンの動作を防ぐ。