「戻る」リンクを直すだけのつもりが、SPA設計を3回見直すことになった話

Claude Code

こんにちは、白々さじきです。

飲み会の日程調整ツールをCloudflare Workers + Hono + D1で作るこの検証プロジェクト。今回はGitHub Issueで見つけた「← トップに戻る」バグの修正に取り組みました。

バグ自体はシンプルに見えました。1行直せば終わる、そう思っていました。

実際には4つの設計判断が連鎖し、8ファイルの変更になりました。


バグの概要:なぜ常にトップに戻るのか

このツールには利用規約・プライバシーポリシー・FAQの3ページがあります。各ページの上部に「← トップに戻る」リンクがあるのですが、どこから来ても常に /(イベント作成ページ)に飛んでしまいます。

// 3ページ全てに同じコードが書かれていた
<Link to="/" className="text-sm text-blue-600 hover:underline">
  ← トップに戻る
</Link>

管理ページから利用規約を開いたら、管理ページに戻りたい。イベントページから来たら、そちらに戻りたい。それが普通の動作です。

そしてこれは、初回実装(Claude Code完全委任)では気づかなかった問題です。

利用規約ページを作るとき、「どこから来るか」を意識していませんでした。「作って終わり」になっていた。Claude Codeへの完全委任では、こういう「遷移元を考慮した動作」が抜けやすいです。これがまさに検証プロジェクトで観測したかった「理解負債」の一形態でした。


調査:フッターリンクに2種類混在していた

修正を始める前に既存コードを調べると、フッターのリンク実装が2種類混在していました。

// footer.tsx
<a href="/terms" target="_blank" rel="noopener noreferrer">
  利用規約
</a>
<a href="/privacy" target="_blank" rel="noopener noreferrer">
  プライバシーポリシー
</a>
<Link to="/faq">
  よくある質問
</Link>

利用規約とプライバシーポリシーは <a target="_blank">新しいタブで開く。FAQは <Link> でSPA内遷移。この違いが、実装方法の選択に影響します。


設計判断①:document.referrer か location.state か

フッターに <a target="_blank"> があったため、「新しいタブで開く遷移元をどう取得するか」という文脈で document.referrer が候補に上がりました。

document.referrer はブラウザ組み込みのプロパティで、「今のページを開く直前にいたページのURL」が入ります。仕組みはHTTPの Referer ヘッダーで、ブラウザがページを読み込む際に自動で付与します。新しいタブで開く場合は新しいHTTPリクエストが発生するため、document.referrer に正しくリンク元URLが入ります。

しかし SPA内遷移(<Link>)では document.referrer は空になります

<Link> はHTTPリクエストを発生させず、JavaScriptで画面を書き換えるだけです。ブラウザ側は「ページ遷移した」と認識しないため Referer ヘッダーを送らず、document.referrer は更新されません。FAQのようにSPA内遷移するページでは使えないとわかりました。(参照:React Router <Link> ※非公式日本語訳)

React Routerにはこういったケース向けの機能があります。<Link>state を渡せば、遷移先で useLocation()(非公式日本語訳)から読み取れます。

// リンク側(footer.tsx)
<Link to="/faq" state={{ from: location.pathname }}>
  よくある質問
</Link>

// 遷移先(faq.tsx)
const location = useLocation();
const backTo = (location.state as { from?: string } | null)?.from ?? '/';

教訓:document.referrer はSPA遷移(<Link>)では使えない。React RouterではLocation stateを使う。


設計判断②:フッターリンクを <Link> に統一する

SPA内のページへのリンクに <a href> を使うのは慣習に反します。

<a href> はフルページリロードを引き起こします。新しいタブで開く場合も、開いた先のページはSPAの文脈から切り離されます。同じアプリ内のページへのリンクには基本的に <Link> を使うべきです。

フッターの3リンクをすべて <Link state={{ from: location.pathname }}> に統一しました。

// 統一後のfooter.tsx
const location = useLocation();

<Link to="/terms" state={{ from: location.pathname }}>利用規約</Link>
<Link to="/privacy" state={{ from: location.pathname }}>プライバシーポリシー</Link>
<Link to="/faq" state={{ from: location.pathname }}>よくある質問</Link>

教訓:SPA内のページへのリンクは外部リンクでない限り <Link> を使う。


設計判断③:「行き止まりページ」のフッターを消す

<Link> に統一したところで、新たな問題が見えてきました。

フッターは全ページに表示されているので、利用規約ページからプライバシーポリシーへ移動すると state.from が上書きされます。

  • 管理ページ → 利用規約:state.from = '/admin/abc123'
  • 利用規約 → プライバシーポリシー:state.from = '/terms'
  • プライバシーポリシーの「← 戻る」→ /terms へ ✓
  • /terms の「← 戻る」→ stateがない → / へ ✗

ここで発想を変えました。そもそも利用規約・プライバシーポリシー・FAQのフッターを消せばいい。

GitHub・Stripe・Notionなど一般的なサービスを見ると、利用規約やプライバシーポリシーのページにはナビゲーションフッターがありません。これらは「読んで戻るだけ」の行き止まりページです。

フッターを消すことで、チェーン遷移の問題を根本から消滅させました。

教訓:ユーザーが「読んで戻る」だけのページにはナビゲーションを置かない。フッターリンクを消すことが設計上正しいこともある。


設計判断④:App.tsx から Footer を外してページ管理に移す

今度は回答送信後のサンクスページが問題になりました。

このツールでは /event/:shareToken(回答フォーム)でフォームを送信すると、同じルートのまま submitted stateが true になり、「回答を送信しました ✅」という画面に切り替わります。

このサンクス画面にフッターが表示されていると、そこから利用規約を開いて「← 戻る」を押すと、フォームページに戻ってしまいます。

問題は、同じルートのサブ状態でフッターの出し分けが必要という点です。 App.tsx のルートベース除外では、同じルートの submitted state を見分けられません。

解決策はシンプルでした。Footer を App.tsx から外して、各ページコンポーネントが直接持つようにする。

// event.tsx
if (submitted) {
  return <div>...サンクス画面(Footerなし)...</div>;
}

return (
  <>
    <div>...フォーム...</div>
    <Footer />  // フォーム表示時のみ
  </>
);

教訓:グローバルレイアウトではコンポーネントの「サブ状態」を制御できない。ページ固有の表示ロジックはページに持たせる。


まとめ:「小さいバグが設計を問い直す」

#設計判断教訓
document.referrer より location.stateSPA遷移にはReferrerが使えない
フッターリンクを <Link> に統一SPA内リンクは <Link> を使う
法的ページのフッターを消す行き止まりページにナビゲーションは不要
Footer を App.tsx からページに移すグローバルレイアウトはサブ状態を制御できない

変更ファイル:App.tsx / footer.tsx / terms.tsx / privacy.tsx / faq.tsx / create-event.tsx / admin.tsx / event.tsx(計8ファイル)

修正するたびに「次の問題」が出てきたのは、表層的なコード修正ではなく設計レベルで直していたからだと思います。最初に <Link to="/"><Link to={backTo}> に書き換えるだけなら5分で終わりました。でもそれをすると別の問題が顕在化する。

Claude Codeとの会話を通じて「なぜそうなっているのか」を問い続けることで、根本的に正しい設計に近づいていきました。

これが完全委任開発の醍醐味かもしれません。実装は委任しても、設計の問いを投げるのは人間の仕事です。

サポートのお願い

下記リンクからお買い物いただけると、ブログ運営のための費用が増え、有料サービスを利用した記事作成が可能になります。ご協力よろしくお願いします!



コメント

タイトルとURLをコピーしました