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
を投げて入口で回避。ページ側は「正常系」だけに集中できます。createBrowserRouter
と loader/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()
のノートを RHF
の defaultValues
に置くだけ。送信中の表示は RHF
の formState.isSubmitting
を使うとシンプルに済みました(useFormStatus
は React
の <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 例は公式が分かりやすかったです。 
使ってみての所感
- 必須は
loader
でawait
、足りなければredirect:ページ
の責務が軽くなり、異常系の分岐が入口に集約されてメンテしやすい。   - “あれば嬉しい”は
Promise
のまま返して<Await>:
初速が落ちず、表示の自然さも保てる。  - RHF の送信中表示は formState.isSubmitting を見るのが実務的。useFormStatus は用途が違う(Server Actions 系)。 
引用・参考リンク
- React Router: Home / モード選択の全体像
https://reactrouter.com/home  - createBrowserRouter(v7 API)
https://api.reactrouter.com/v7/functions/react_router.createBrowserRouter.html  - redirect(loader からのリダイレクト)
https://api.reactrouter.com/v7/functions/react_router.redirect.html  - Streaming with Suspense(loader は必須を await、非必須は Promise で)
https://reactrouter.com/how-to/suspense  - Await(Promise を描画するコンポーネント)
https://api.reactrouter.com/v7/functions/react_router.Await.html  - React: useFormStatus(React Hook Form とは別系統)
https://react.dev/reference/react-dom/hooks/useFormStatus 
Discussion