React Router v6.4 のチュートリアルを通してloader, action を使った画面遷移と処理の理解

React
Reactフロントエンド

本記事は React Router のチュートリアル の中ででてくる処理の理解をより深めるために試行した結果をまとめたものです。

動作確認バージョン

主なライブラリのバージョンは以下

├── @types/react-dom@18.0.9
├── @types/react@18.0.26
├── @vitejs/plugin-react@3.0.0
├── localforage@1.10.0
├── match-sorter@6.3.1
├── react-dom@18.2.0
├── react-router-dom@6.4.5
├── react@18.2.0
├── sort-by@1.2.0
├── typescript@4.9.4
└── vite@4.0.0

ルーティングの定義

以下は、React Router のチュートリアル の JSX の定義です。

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route
      path="/"
      element={<Root />}
      loader={rootLoader}
      action={rootAction}
      errorElement={<ErrorPage />}
    >
      <Route errorElement={<div>Oops! There was an error2.</div>}>
        <Route index element={<Index />} />
        <Route
          path="contacts/:contactId"
          element={<Contact />}
          loader={contactLoader}
          action={contactAction}
        />
        <Route
          path="contacts/:contactId/edit"
          element={<EditContact />}
          loader={contactLoader}
          action={editAction}
        />
        <Route path="contacts/:contactId/destroy" action={destroyAction} />
      </Route>
    </Route>,
  ),
)

createBrowserRouter

createBrowserRouter でルーティングの定義が作れる。
上記は JSX 形式だが、オブジェクトベースで作成することも可能。

Route

ルーティングの定義で、ネストすることも可能。

プロパティ説明
pathURLのパス。ネストした場合は、親からの連結になる
elementpath に対応するコンポーネント

contacts/:contactId/destroy のように element を持たない Route もあり、この場合は、当該 path に対する action 処理のみを実行する(このパスの画面があるわけではない)。この状態で URL アクセスすると、以下の警告になる
「react_devtools_backend.js:4026 Matched leaf route at location “xxx” does not have an element. This means it will render an with a null value by default resulting in an “empty” page.」
loaderページのレンダリング前に実行される処理
ユーザ操作で画面遷移や redirect されてページが表示されたタイミングで、React Router がページ上のすべての loader を呼び出す。
結果、最新の値でページが表示することができる。
actionReact Router の Form で POST されたときの処理。
後述で補足。
children子要素。親側の要素内の <Outlet> 部に表示される。
errorElementエラー時の表示内容。
指定要素の path 内の処理、または loader の処理(サーバへのリクエストなど)時にエラーが発生してデータが取得できなかった場合、例外をスローさせる。
すると、この要素が表示される。
未指定の場合は親の errorElement となる。
上記の例のように children でネストすることで、子の処理に対して一括で errorElememnt を設定もできる。
{ index: true, element: <Index /> },React Router の Navlink が未選択(isActiveがない)場合に表示される要素
Route に指定できる主な項目

loader

上の例定義の場合の遷移と操作の場合に実行される loader は以下のような結果となりました。

操作実行された loader
/ へ遷移rootLoader
/contacts/:contactId へ遷移contactLoader
/contacts/:contactId/edit へ遷移contactLoader
/contacts/:contactId/edit 画面で editAction 実行(実行後画面 は/contacts/:contactId へ遷移)rootLoader
contactLoader
/contacts/:contactId 画面で destoryAction 実行(実行後画面は / へ遷移)rootLoader
/ 画面で rootAction 実行rootLoader
contactLoader
操作に応じて実行された loader の確認結果

まとめると

  • 遷移するとそのページの loader が実行される
  • ネストしている場合は、ネストした遷移先の loader が実行される(URL直でネストしたパスに遷移した場合は親の loader も一緒に実行される)

action

action は、Form が post メソッドで submit されたタイミングで呼び出される。
Form が get メソッドで呼び出された場合には実行されない。
※ 正確には「get 以外の場合 (”post”、”put”、”patch”、”delete”)」 に呼び出されるとのこと。

以下もチュートリアルにある実装例。

// http://localhost:5173/contacts/jzyrzrl という
// URL で編集画面表示中に保存操作を行った場合の例

// 該当する Route 定義
// path にある動的パラメータ :contanctId が以下の引数の params に入る
<Route
  path="contacts/:contactId"
  element={<Contact />}
  loader={contactLoader}
  action={contactAction}
/>

// request, params は後述の画像のようなデータが渡される。
// request.formData() に画面上で入力した情報を持っている(キーは input 要素の name 属性)
// params は URL パラメータ。以下の画像の contactId は
// ルーティング定義の path="contacts/:contactId の動的マッチングの名前
export async function action({ request, params }) {
  console.log(request) // 出力内容は以下
  console.log(params) // 出力内容は以下
  const formData = await request.formData()
  const firstName = formData.get('first')
  const lastName = formData.get('last')
  const updates = Object.fromEntries(formData)
  await updateContact(params.contactId, updates)
  return redirect(`/contacts/${params.contactId}`)
}
action 実行時に引数で渡される request, params の値
action 実行時に引数で渡される request, params の値

Form / fetcher.Form

Form

チュートリアルで使われている Form 部分を抽出。忘れないよう、それぞれコメントを記載。

/* Form はデフォルトでは GET
   action は相対パス値を取る。以下の場合は、contact/:id/edit への遷移
   遷移先の action は実行されない。*/
          <Form action="edit">
            <button type="submit">Edit</button>
          </Form>

/* action は相対パス値を取る。contact/:id/destroy
   ここでは POST になっているので、confirm で true の場合は、destroy の action が呼ばれる */
          <Form
            method="post"
            action="destroy"
            onSubmit={(event) => {
              if (!confirm('Please confirm you want to delete this record.')) {
                event.preventDefault()
              }
            }}
          >

/* useSubmit を使うことで任意のイベント時に submit をかけることができる。
   これは const submit = useSubmit() された結果を onChange 内で実行している。
   GET メソッドなので、action は実行されない。
   URL はクエリがついたものに更新される、結果 loader のみ都度実行される)。 */
          <Form id="search-form" role="search">
            <input
              id="q"
              aria-label="Search contacts"
              placeholder="Search"
              type="search"
              name="q"
              defaultValue={q}
              className={searching ? 'loading' : ''}
              onChange={(event) => {
                const isFirstSearch = q == null
                submit(event.currentTarget.form, {
                  replace: !isFirstSearch,
                })
              }}
            />
            <div id="search-spinner" aria-hidden hidden={!searching} />
            <div className="sr-only" aria-live="polite"></div>
          </Form>

/* POST なので、表示中の path に対する action が実行される */
          <Form method="post">
            <button type="submit">New</button>
          </Form>

fetcher.Form

ブラウザの URL を変えずにサーバ通信を行う場合はこちらを利用する。

function Favorite({ contact }: { contact: { favorite: boolean } }) {
  const fetcher = useFetcher()
  let favorite = contact.favorite

  if (fetcher.formData) {
    favorite = fetcher.formData.get('favorite') === 'true'
  }

  return (
/* 
  チュートリアルでは、path="contacts/:contactId" の action を実行する。
  例えば、
    <Route path="contacts/:contactId/favorite" action={contactAction} />
  のような Route を作成し、fetcher.Form の action を "favorite" とすれば、
  上記の URL の action を実行してくれる、かつ画面のURLは変更なし。
 */
    <fetcher.Form method="post">
      {' '}
      <button
        name="favorite"
        value={favorite ? 'false' : 'true'}
        aria-label={favorite ? 'Remove from favorites' : 'Add to favorites'}
      >
        {favorite ? '★' : '☆'}
      </button>
    </fetcher.Form>
  )
}

NavLink

こちらもチュートリアルのコードにコメント追記。

画面左にメニューがあり、右がそれに対応する画面、という構成はよくあると思いますが、この仕組を使うと選択状態と画面の同期を自分で管理せず実現できますね。便利です。

contacts.map((contact) => (
  <li key={contact.id}>
    {/* 
      className に関数を指定することで状態をもとにcssクラスを適用することができる。
        isActive : 今表示している画面のURLが to のURL
      isPending: 今表示中の画面のURLが to のURL。表示中だよ、をユーザにフィードバックできる
    */}
    <NavLink
      to={`contacts/${contact.id}`}
      className={({ isActive, isPending }) =>
        isActive ? 'active' : isPending ? 'pending' : ''
      }
    >
      {contact.first || contact.last ? (
        <>
          {contact.first} {contact.last}
        </>
      ) : (
        <i>No Name</i>
      )}{' '}
      {contact.favorite && <span>★</span>}
    </NavLink>
  </li>
))

所感

loader, action 含め便利だなと思いつつ、これらをフル活用した形で作るのがよいのか、以前?のように、Router には画面遷移にのみ徹してもらって、データ取得周りの処理は別にした方が良いのか、棲み分けを考えないといけないなと思いました。ただ、この仕組を使うと Route の定義は増えそうだけど、その分 サーバ通信前後のハンドリングはしやすくなりそうだなと感じました。

コメント