Remix のフォームとデータ取得メカニズムを探る
最近 remix の登場がTwitterで大きな反響を呼び、多くのフロントエンドコミュニティで話題になっている。ちょうど最近社内ツールの開発があったので、思い立って試してみることにした。使ってみた感覚としてはNext.jsにとても似ていて、Routeベースのアプローチを主軸にし、getServerSideProps のような規定された関数を通じてサーバー側でデータを取得してSSRを行う。
Remixはreact-routerの作者であるRyan Florenceによって作られた。当初は有料路線を進む予定だったが、後にオープンソース化された。「また新しいフレームワークか」と思う人も少なくないかもしれないが、Remixには確かに独自の特徴がある。ドキュメントを読んだことがある人なら最も顕著に感じるのは、Remixのフォームに対するこだわりだろう。これこそがRemixと他のSSRフレームワークとの最大の違いであるはずだ。今振り返ってみると、フォームの処理はフロントエンドに入門したばかりの頃にとても混乱しやすかったことでもある。
あれ、でもブラウザのフォームってページがリロードされるよね?それじゃ要件を満たせない!
Remixはフォーム処理の部分に多くの工夫を凝らしている。formを使って非同期リクエストを実装できるだけでなく、JavaScriptが無効になっている場合でもブラウザ標準のフォーム操作にフォールバックし、リロードは発生するものの少なくとも送信機能自体は動作し続ける。(それに、時と場合によってはフォームを直接送信してページをリロードする方が、非同期よりも体験が良いと僕は感じることがある🤔)
この記事ではRemixの各機能を深く紹介するのではなく、フォーム処理とデータ取得という2つの部分に焦点を当てて探っていく。
フォーム処理のメリットとは?
大量のJavaScriptコードを書く必要がなく、入力値を保持するために変数(またはstate)を別途用意する必要もない。例えば以下の例だ:
<form action="/my/api" method="post">
<input name="user_name" type="text" />
<input name="world" type="text" />
<button type="submit">
Submit
</button>
</form>
デフォルトの状態(コードによる介入が一切ない場合)、ボタンをクリックするとブラウザは自動的に /my/api へ POST メソッドでリクエストを送信し、form dataの形式で user_name と world のフィールドを送信する。
しかし、デフォルトの挙動を完全に実現するにはサーバー側の支援が必要だ。例えばサーバーがJSONしか返さない場合、ユーザーのページは同じ場所にとどまってしまう。そのため一般的なアプリケーションでは、サーバーは通常301などのリダイレクトを返し、成功ページや失敗ページなどの別ページへ遷移させる。
時として、ページ全体を直接リロードした方が物事がはるかにシンプルになる場合がある。フロントエンドで各種の状態やhistoryの操作を管理する必要がなくなるからだ。しかし、リアルタイムのコメントやチャットのように即時性が求められるアプリケーションでは、ページのリロードはあまり理想的ではない。
では、フォームを非同期処理にするにはどうすればよいか?submitイベントでデフォルトの挙動を防いだ上で、コードを書いて送信すればよい:
<form action="/my/api" method="post" onSubmit={e => {
e.preventDefault()
// call API!
}}>
<input name="user_name" type="text" />
<input name="world" type="text" />
<button type="submit">
Submit
</button>
</form>
こう書く最大のメリットは、フォーム操作全体をブラウザのメカニズムに任せられる点にあり、ブラウザ側では FormData を通じてフォームの値を簡単に取得できる:
<form action="/my/api" method="post" onSubmit={e => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const userName = formData.get('user_name')
const world = formData.get('world')
}}>
<input name="user_name" type="text" />
<input name="world" type="text" />
<button type="submit">
Submit
</button>
</form>
これにより、refやstateを別途用意しなくても入力値を取得できる。フォーム本来のネイティブな仕組みを利用しない場合、Reactでは以下のように書くかもしれない:
const Component = () => {
const [value, setVal1] = useState()
const [value, setVal2] = useState()
const [value, setVal3] = useState()
return <>
<input value={value1} onChange={handleChange} />
<input value={value2} onChange={handleChange} />
<input value={value3} onChange={handleChange} />
</>
}
見てわかるように、inputフィールドが増えると useState も増え、記述が少々煩雑になってくる。もちろんこの部分は抽象化レイヤーを1つ挟むことで解決できるが、それによって抽象化の手間とオーバーヘッドが増えることになる。
だが、フォームを一度非同期に変更すると、考慮すべき状態が数多く出てくる:
- ローディング中:フォームの送信中。この時はユーザーの誤操作を防ぐためにフィールドをすべてdisabledにする必要がある
- バリデーションエラー:フィールドの値を保持したままエラーメッセージを表示する必要がある
- API成功:対応する通知を表示し、フォームをクリアする
Remixでは、フォーム送信後のリダイレクトと非同期フォームアプリケーションの双方に対してAPIのサポートを提供している。僕がこの設計を気に入っている理由は、単にシンプルなフォーム送信を行いたいだけなのに、わざわざ大掛かりな非同期処理を行って大量のコードを書く必要がないケースが実に多いからだ。
同期(フォームのネイティブな仕組みを使用)
ネイティブの状況では、手順通りにフォームの内容を定義するだけでよく、stateを宣言する必要は全くない。
// routes/users/index.js
export default function UserProfile() {
return <form method="post" action="/users">
<label>
Name: <input type="text" name="userName" />
</label>
<label>
Age: <input type="text" name="age" />
</label>
<button>Submit</button>
</form>
}
サーバー側には対応する処理を追加する必要があり、Remixの手法では同一ファイル内に action と呼ばれる関数を追加する:
import { redirect } from 'remix';
export async function action({ request }) {
const formData = await request.formData()
const user = await createUser(formData)
return redirect(`/users/${user.id}`)
}
// routes/users/index.js
export default function UserProfile() {
return <form method="post" action="/users">
<label>
Name: <input type="text" name="userName" />
</label>
<label>
Age: <input type="text" name="age" />
</label>
<button>Submit</button>
</form>
}
HTTPメソッドがGETではない場合、action関数が呼び出されるため、action関数の中でHTTPメソッドが何であるかを判定することになる。
この redirect 関数はRemixによって提供されており、サーバーサイドで users/id ページへとリダイレクトを行う。ここで重要なのは、リダイレクトが history.push を使うのではなく、サーバーサイドで発生するという点だ。フォームのバリデーションやサーバー側でエラーが返された場合は、useActionData を通じてサーバーが返したデータを取得できる。
export async function action({ request }) {
const formData = await request.formData()
+ if (someError) {
+ json({ error: message })
+ }
return redirect(`/users/${user.id}`)
}
// routes/users/index.js
export default UserProfile() {
+ const actionData = useActionData()
return <form method="post" action="/users">
<label>
Name: <input type="text" name="userName" />
</label>
<label>
Age: <input type="text" name="age" />
</label>
+ {actionData.error && <p>{actionData.error}</p>}
<button>Submit</button>
</form>
}
非同期
非同期の要件に対して、Remixは開発者向けに Form コンポーネントを提供している。現在のフォーム送信状態を表示するために、別途 useTransition を利用して表現できる:
import { Form, useTransition } from 'remix'
// routes/users/index.js
export default function UserProfile() {
const actionData = useActionData()
const transition = useTransition()
return <form method="post" action="/users">
<label>
Name:
<input
type="text"
name="userName"
disabled={transition.state === 'submitting'}
/>
</label>
{actionData.error && <p>{actionData.error}</p>}
<button>Submit</button>
</form>
}
これら2つの仕組みに加えて、useSubmit を使ってフォーム送信のタイミングを自分で決定することもできる。同期・非同期を問わず、Remixのフォーム処理に対する思想は、フォーム処理を送信した後に何らかの操作を経て、特定のページへ遷移するというものだ。
データ取得の仕組み - loader と fetcher
ウェブにおいて一般的なデータ取得のタイミングは、主に以下の2種類がある:
- ユーザーが
<a>やアドレスバーからページにアクセスした際にページデータを取得する(ブログ記事など) - ユーザーが何らかの操作を行ってからAPIを呼び出してデータを取得する(コメント欄をクリックした後にAPIを呼び出してデータを取得するなど)
1つ目のケースに対して、Remixはloaderの仕組みを提供している。これはサーバーサイドでのみレンダリングされるため、コンポーネントがレンダリングされる時点ですでにデータが存在する状態となり、ローディング表示後にデータをレンダリングするような操作やコード実装が不要になる。
2つ目のケースに対しては、Remixはfetcherの仕組みを提供している。useFetcher を使ってmutation(データの更新)やデータ取得が行える:
const MyComponent = () => {
const fetcher = useFetcher()
useEffect(() => {
fetcher.load('/comments')
}, [])
if (fetcher.state === 'loading') {
return <p>loading...</p>
}
return <div>
<Comments comments={fetcher.data.comments} />
</div>
}
データの取得だけでなく、fetcherはmutation(データ更新)にも利用できる:
const MyComponent = ({ content }) => {
const fetcher = useFetcher()
return <fetcher.Form method="post" action="/comments">
<input type="text" name="content"></input>
<button type="submit">Submit</button>
</fetcher.Form>
}
Remixの裏側では、前述したような操作を自動的に処理してくれる:
- submitをクリックした際に状態をsubmittingに変更し、ローディングUIの実装を容易にする
- データが返された後、
fetcher.dataで取得可能 fetcher.submitでフォーム内容を直接送信するが、ページ遷移は行わない(裏側では実質的にAPI呼び出しと各種ステータス処理が行われている)
Edge Computing
もう1つのポイントは、Remixがしきりに強調しているいわゆるEdge server(CDN)の概念だ。これは単に純粋な静的ページをCDNにデプロイするだけでなく、サーバーを各エッジノードにデプロイしてNetwork trafficを削減するというものだ。
確かに、ユーザーのデバイススペックがもはやボトルネックではないとすれば、唯一影響を与えるのはネットワーク速度だ。ここでのエッジコンピューティングとは、サーバーをユーザーに近い地域にデプロイすることで、パケットのルーティングを減らすことを指している。もっとも、一部のアプリケーションにおいては、サービスの対象が特定の地域のユーザーに限定されているため、それほど大きな差はないかもしれない。
まとめ
僕がRemixのアプローチを気に入っているのは、フォームに対するそのこだわりが、かつて僕自身も抱いていた考えを思い出させてくれたからだ。ネイティブのフォーム機構を通じることで大量のvalueの状態宣言を省くことができ、フォーム処理の仕組みを活用すれば、様々な非同期APIを書くことなくシンプルなユースケースに対応できる。開発時間が足りない状況では、非同期処理において考慮すべき事項が多く、うまく処理できないと往々にして従来のフォームよりもユーザー体験が悪くなってしまう。
Remixはあらゆるユースケースを兼ね備えている。JavaScriptがない環境ではネイティブのフォーム機構で動作し、即時性が必要な場合はFormコンポーネントを組み合わせて利用でき、よりリッチなSPA的なインタラクションが必要な場合はfetcherを活用できる。フロントエンドが最も注力するデータ処理において、課題の大半を解決してくれていると言えるだろう。
関連記事
- Three.js で僕の部屋を表現する 僕が React Three Fiber を使って実際の自分の部屋をブラウザ上に再現し、実世界のオブジェクトを目録に見立て、空間の記憶を通してここ数年の生活と仕事について語った話。
- フロントエンドで画像を扱う際に注意すべきこと Jake Archibaldの記事を起点に、現代のレスポンシブ画像の書き方を整理する。なぜwidth/heightを付ける必要があるのか、CSSのaspect-ratioはいつ使うべきか、AVIFとWebPの選び方、そしてpicture/source/srcsetを使ったモバイル向け画像の切り替えについて。
- CSS field-sizing — たった1行のCSSでフォーム要素を自動リサイズする かつてtextareaの自動高さ調整は、JavaScriptでscrollHeightを監視するしかなかった。しかしCSSのfield-sizing: contentなら、わずか1行で代替でき、textarea、input、selectに対応している。本記事では従来のやり方のペインポイントと、field-sizingの使い方をまとめる。
- リンクの下線をもっと見栄え良くする:text-underline-offset デフォルトでは下線と文字が近すぎて、このスタイルを好まないデザイナーもいるし、僕自身もあまり綺麗ではないと感じていた。