📌

react-routerでデータ取得場所に迷ったときのメモ

に公開

はじめに

個人開発でメモアプリを作っていて、「編集フォームの初期値(既存ノート)をどこで取得するか」で迷走しました。最初はページ側で全部やっていたのですが、React Router v7 の loader と Suspense + <Await> に寄せたら気持ちよく落ち着いた、という記録です。

最初の実装

最初はページに入ってから useParams で id を取り、ページ内のカスタムフックで fetch、取れたら RHF の reset で初期値を流し込むという形で行いました。

// 以前の形(抜粋)
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useParams, Navigate, useNavigate } from "react-router-dom";
import { zodResolver } from "@hookform/resolvers/zod";
import { UpdateNoteSchema, type UpdateNote } from "../types/note";
import { useNote, useNotes } from "../hooks/useNotes";

export default function NoteEditPage() {
  const { id } = useParams();
  if (!id) return <Navigate to="/" replace />;

  const { data: note, isLoading, error } = useNote(id);
  const { updateNote } = useNotes(1);
  const navigate = useNavigate();

  if (isLoading) return <div>Loading...</div>;
  if (error || !note) return <Navigate to="/" replace />;

  const { handleSubmit, control, reset, formState } = useForm<UpdateNote>({
    resolver: zodResolver(UpdateNoteSchema),
    defaultValues: { id: note.id, title: note.title, content: note.content },
  });

  useEffect(() => {
    reset({ id: note.id, title: note.title, content: note.content });
  }, [note, reset]);

  return (
    <form onSubmit={handleSubmit(async (data) => {
      await updateNote(data);
      navigate("/");
    })}>
      {/* フィールド… */}
      <button type="submit" disabled={formState.isSubmitting}>
        {formState.isSubmitting ? "更新中" : "データを更新"}
      </button>
    </form>
  );
}

やってみると、毎ページでローディングやエラー分岐を書くことになって少しめんどうでした。
編集ページは「対象ノートが必須」なので、なければそもそもページに来てほしくない。じゃあルーター側で“ないなら入れない”を先に判定したらスッキリするのでは?と思い始めました。

ルーターの loader で先に整える

そこで ルート定義の loader で 必須データだけ await してからページを出す形に変えました。もし id が不正だったり、ノートが見つからなければ redirect を投げて入口で回避。ページ側は「正常系」だけに集中できます。createBrowserRouterloader/redirect は v7 の一次情報が安心でした。  

// routes.tsx の該当ルート(抜粋)
import { createBrowserRouter, RouterProvider, redirect } from "react-router-dom";
import NoteEditPage from "./pages/NoteEditPage";
import { fetchNote } from "./hooks/useNotes";

const router = createBrowserRouter([
  {
    path: "/",
    children: [
      {
        path: "edit/:id",
        element: <NoteEditPage />,
        loader: async ({ params }) => {
          const id = params.id;
          if (!id) throw redirect("/");
          const note = await fetchNote(id);
          if (!note) throw redirect("/");
          return note; // useLoaderData() でそのまま受け取れる
        },
      },
    ],
  },
]);

export default function AppRouter() {
  return <RouterProvider router={router} />;
}

ページ側は useLoaderData() のノートを RHFdefaultValues に置くだけ。送信中の表示は RHFformState.isSubmitting を使うとシンプルに済みました(useFormStatusReact<form action> 連携のための Hook で、RHFの送信状態とは別物)。 

「関連データも欲しい」には <Await> が気持ちよかった

使っているうちに「関連ノート一覧も出したいな」と欲が出ました。ただ、関連は“あれば嬉しい”類。ここは 最初から待たずに Promise のまま返す → コンポーネント側で <Suspense><Await> で解決する形に。React Router は loader から Promise を返すと Suspense による段階的レンダリングをサポートしてくれるので、最初の表示をブロックせずに済みます。  

// loader 側(必須は await、関連は Promise のまま)
export async function loader({ params }: { params: { id: string } }) {
  const note = await fetchNote(params.id);           // 必須
  const related = fetchRelatedNotes(note.id);        // あとから
  return { note, related };                          // Promise のまま返す
}

// コンポーネント側(関連だけ後追いで解決)
import { Suspense } from "react";
import { useLoaderData, Await } from "react-router-dom";

type Data = { note: Note; related: Promise<Note[]> };
const { note, related } = useLoaderData() as Data;

<Suspense fallback={<div>関連読み込み中…</div>}>
  <Await resolve={related}>
    {(items) => <RelatedList items={items} />}
  </Await>
</Suspense>

この形にしてから、編集ページはすぐ出る(必須は loader で確実に揃う)、関連は読み込み完了で“スッ”と出てくるという素直な挙動になりました。Await の API 例は公式が分かりやすかったです。 

使ってみての所感

  • 必須は loaderawait、足りなければ redirect:ページの責務が軽くなり、異常系の分岐が入口に集約されてメンテしやすい。  
  • “あれば嬉しい”は Promise のまま返して <Await>:初速が落ちず、表示の自然さも保てる。 
  • RHF の送信中表示は formState.isSubmitting を見るのが実務的。useFormStatus は用途が違う(Server Actions 系)。 

引用・参考リンク

Discussion