リミックスのフォームとデータ読み取りメカニズムの探討

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

最近、remix の登場がTwitterで大きな反響を呼んでおり、多くのフロントエンドコミュニティが議論しています。ちょうど最近、内部ツールの開発を予定していたので、思い立って試してみることにしました。体感的にはnext.jsに似ており、ルートベースのアプローチを採用しており、getServerSidePropsのような規定の関数を通じてサーバーがデータを取得し、SSR(サーバーサイドレンダリング)を行います。

remixはreact-routerの作者であるRyan Florenceによって作成され、当初は有料での提供を予定していましたが、後にオープンソースに変更されました。「また新しいフレームワークか」と感じる方も多いかもしれませんが、remixには独自の特徴があります。ドキュメントを読んだり、明らかに感じられるのは、remixがフォームに対して非常にこだわっている点です。これがremixと他のSSRフレームワークとの最大の違いでもあります。振り返ってみると、フォームの取り扱いはフロントエンドを始めたばかりの頃には混乱しやすい部分でもありました。

えっ、でもブラウザのフォームってリロードしちゃうんじゃないの?これじゃニーズに合わないよ!

remixはフォーム処理に多くの工夫を凝らしています。非同期リクエストを実装するためにフォームを使用することができ、JavaScriptが無効な場合でも、ブラウザの組み込みフォーム操作にフォールバックします。リロードは必要ですが、少なくとも送信機能は動作します。(そして、時にはフォームを直接送信してリロードする方が非同期よりも良い体験になることがあります🤔)

この記事では、remixの様々な機能を深く掘り下げるのではなく、フォーム処理とデータ読み込みの2つの部分に焦点を当てて探ります。

フォーム処理のメリットは?

たくさんのJavaScriptコードを書く必要もなく、入力値を別の変数(またはステート)に保存する必要もありません。例えば、以下の例を見てみてください:

<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メソッドでリクエストを発信し、フォームデータとしてuser_nameworldフィールドを送信します。

ただし、デフォルトの動作はサーバーの助けが必要で、例えばサーバーがJSONのみを返す場合、ユーザーのページは同じページに留まります。一般的なアプリケーションでは、サーバーは通常、成功ページや失敗ページなど他のページに301リダイレクトを返します。

時には、ページ全体をリロードする方が物事を単純にしてくれることもあります。なぜなら、フロントエンドでは様々なステートや履歴の操作を維持する必要がなくなるからです。しかし、リアルタイムメッセージやチャットなど、即時性が重視されるアプリケーションにおいては、ページのリロードはあまり理想的ではありません。

では、どうやってフォームを非同期操作に変えるのでしょうか?submitイベントでデフォルトの動作を無効にし、コードを書いて送信すれば良いのです:

<form action="/my/api" method="post" onSubmit={e => {
  e.preventDefault()
  // 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 [value1, setValue1] = useState()
  const [value2, setValue2] = useState()
  const [value3, setValue3] = useState()
  return <>
    <input value={value1} onChange={handleChange} />
    <input value={value2} onChange={handleChange} />
    <input value={value3} onChange={handleChange} />
  </>
}

inputフィールドが増えると、useStateも増え、記述が煩雑になってしまうことがわかります。この部分は抽象化することも可能ですが、そうなるとさらに抽象化の層が増え、オーバーヘッドが追加されます。

しかし、フォームを非同期に変更すると、考慮すべき多くの状態が出てきます:

  • ローディング中:フォームが送信中であるため、フィールドをすべてdisabledにしてユーザーの操作を防ぐ必要があります
  • フォーム検証エラー:フィールドの値を保持し、エラーメッセージを表示する必要があります
  • API成功:対応するメッセージを表示し、フォームをクリアします

remixでは、フォーム送信後のリダイレクトや非同期フォームの動作に対してAPIサポートが提供されています。このようにすることが好きな理由は、非同期処理を大掛かりに行う必要がない場合が多いからです。簡単なフォーム送信のために大量のコードを書く必要はありません。

同期(フォームのネイティブメカニズムを使用)

ネイティブの状況では、フォームの内容を順を追って定義するだけで、ステートを宣言することなく実現できます。

// 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つあります:

  1. ユーザーが<a>やURLバーを通じてページにアクセスした際にページデータを読み込む(例:ブログ記事)
  2. ユーザーが特定の操作を行った後にAPIを呼び出してデータを取得する(例:コメントセクションをクリックしてAPIからデータを取得)

最初のケースでは、remixはloaderメカニズムを提供しており、サーバーサイドレンダリングのみが行われるため、コンポーネントがレンダリングされる時にはすでにデータが存在する状態となり、ローディング後にデータをレンダリングする操作やコードの実装が不要になります。

2つ目のケースでは、remixはfetcherメカニズムを提供しており、useFetcherを使用してミューテーションやデータの読み取りを行うことができます:

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はミューテーションにも使用できます:

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を呼び出し、様々な状態を処理しています)

エッジコンピューティング

もう一つのポイントは、remixがエッジサーバー(CDN)の概念を強調していることです。これは純粋な静的ウェブページをCDNにデプロイするだけでなく、サーバーを各ノードにデプロイしてネットワークトラフィックを減少させることを意味します

確かに、ユーザーのデバイス性能がボトルネックでなくなった場合、唯一影響を与えるのはネットワーク速度です。ここでのエッジコンピューティングは、サーバーをユーザーの近くに配置し、パケットのルーティングを減少させることを指します。ただし、特定のアプリケーションにおいては、ターゲットとなるユーザーが地元のユーザーである場合、違いはそれほど大きくないかもしれません。

まとめ

私がremixの処理方法を好む理由は、フォームへのこだわりが以前の自分の考えを思い起こさせるからです。ネイティブのフォームメカニズムを活用することで、多くのvalueステート宣言を省略でき、フォームの処理メカニズムを使っていくつかのシンプルなアプリケーションシナリオに対応できるからです。非同期処理を考慮する場合、開発時間が不足している状況では、多くの要素を考慮する必要があるため、1つの要素が適切に処理されていないと、従来のフォームよりも悪い体験になってしまいます。

remixは様々なシナリオを考慮しており、JavaScriptが無い状況でもネイティブのフォームメカニズムを利用でき、即時性が求められる場合はFormコンポーネントを使用して組み立てることができ、より多くのSPAのインタラクションが必要な場合はfetcherを利用できます。フロントエンドで最も重要なデータ処理に関する問題がほぼ解決されているのです。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee