• #nuxt3
  • #vue-router
  • #pinia
  • #refactoring
  • #tax-assistant
開発tax-assistantメモ

Nuxt3でページベースルーティングへ移行

tax-assistantプロジェクトで、単一ページ + クエリパラメータベースのタブ管理から、Nuxt3のページベースルーティング + Pinia状態管理へ移行を行った。

背景:なぜ移行が必要だったのか

従来の実装では、全てのタブを単一ページ(index.vue)内で管理し、/?tab=receipt/?tab=creditcard のようにクエリパラメータでタブを切り替えていた。

この方式には以下の問題があった。

問題1:履歴管理コードの複雑さ

クエリパラメータベースでは、ブラウザの「戻る/進む」機能(Alt+矢印)を動作させるために、明示的な実装が必要だった。

// 従来の useTabQuerySync.ts での履歴管理
function updateQueryParams(isTabChange = false) {
  // タブ切替 → push(戻るボタンで前のタブに戻れる)
  // フィルタ変更 → replace(履歴が大量に積まれるのを防ぐ)
  if (isTabChange) {
    router.push({ query })
  } else {
    router.replace({ query })
  }
}

この「push と replace の使い分け」が常に正しく行われる必要があり、バグの温床になっていた。

問題2:初期ロード時の履歴上書き

遷移先のコンポーネントが初期化時に router.replace() を呼び出すと、ユーザーの遷移履歴を上書きしてしまう問題があった。

1. クレカ明細で「仕訳ルール編集」リンクをクリック
2. ブラウザが履歴に新しいエントリを追加
3. RuleListView.vue がマウント
4. onMounted → loadData() が実行
5. loadData() 内で selectRuleByIndex() を呼び出し
6. selectRuleByIndex() → updateQueryParams() → router.replace({ query })
7. replace により直前の履歴エントリが上書きされる
8. 結果:履歴に「クレカ明細」のエントリがなくなり、戻れない

Codexレビューで指摘された問題点

計画ドキュメント(page-based-routing-refactor-plan.md)をCodex CLI(GPT-5.2)にレビュー依頼した結果、以下の指摘を受けた。

高優先度

  • 移行計画の矛盾: Phase 0の「全テストパスでPhase 1へ」と、履歴テスト5件失敗の修正はPhase 2という記述が矛盾
  • 改善案: 履歴修正をPhase 1に前倒し、またはPhase 0完了条件を「既知不具合は追跡チケット化し除外」に変更

中優先度

  • テスト計画の不足: キーボードショートカットのE2E具体テストが不足、上下矢印無効化の保証が未カバー
  • クエリパラメータ異常系未定義: 欠落、不正値、組み合わせ不整合時のフォールバック規則が未定義
  • URL設計の不整合: パラメータ名が混在(docType/typeid/index
  • 旧URLリダイレクト: 301を使うとブラウザキャッシュで固定化される恐れ → 302/307を推奨

低優先度

  • Piniaユニットテストのフレーク要因: 外部依存(loadBatches())を叩く可能性

これらの指摘を踏まえて計画を修正し、実装を進めた。

実装:Phase 1-3の詳細

Phase 1:準備(既存機能に影響なし)

Piniaストアの作成

5つのストアを新規作成した。

// stores/navigation.ts - ナビゲーション状態
export const useNavigationStore = defineStore('navigation', () => {
  const previousRoute = ref<string | null>(null)
  const selectedYear = ref<string | null>(null)
  const selectedMonth = ref<string | null>(null)

  function $reset() {
    previousRoute.value = null
    selectedYear.value = null
    selectedMonth.value = null
  }

  return { previousRoute, selectedYear, selectedMonth, $reset }
})

// stores/batches.ts - バッチ情報
export const useBatchesStore = defineStore('batches', () => {
  const batches = ref<Batch[]>([])
  const currentBatchId = ref<string | null>(null)

  async function loadBatches() { /* ... */ }

  function $reset() {
    batches.value = []
    currentBatchId.value = null
  }

  return { batches, currentBatchId, loadBatches, $reset }
})

// stores/receipts.ts - レシートデータ
// stores/client.ts - クライアント情報
// stores/ui.ts - UI状態(プレビュー幅など)

ストアリセット方針も定義した。

イベントリセットするストア保持するストア
クライアント切替batches, navigation, receiptsclient, ui
ページ離脱(リロード)全て-
タブ間遷移なし全て

共通レイアウトの作成

layouts/new-default.vue として新しいレイアウトを作成。この時点では既存に適用しない。

<!-- layouts/new-default.vue -->
<template>
  <div class="app">
    <header class="header">
      <h1>Tax Assistant</h1>
      <span class="client-name">{{ clientName }}</span>
      <nav class="tabs">
        <NuxtLink to="/vouchers" class="tab">読取一覧</NuxtLink>
        <NuxtLink to="/by-account" class="tab">科目別一覧</NuxtLink>
        <NuxtLink to="/duplicate" class="tab">重複チェック</NuxtLink>
        <!-- ... -->
      </nav>
    </header>
    <main>
      <slot />
    </main>
  </div>
</template>

旧URL互換ミドルウェアの作成(無効化状態)

// middleware/legacy-redirect.global.ts
export default defineNuxtRouteMiddleware((to) => {
  // 1. 旧URLの検出とリダイレクト
  if (to.path === '/' && to.query.tab) {
    const tab = to.query.tab as string

    const tabMigrations: Record<string, string> = {
      'receipt': '/vouchers',
      'miller': '/by-account',
      'duplicate': '/duplicate',
      'result': '/result',
      'square': '/square',
      'creditcard': '/creditcard',
      'shiwake-rules': '/shiwake-rules',
      'matrix': '/matrix',
      'account-master': '/account-master',
      'journal': '/journal',
      'document-types': '/document-types',
    }
    const newPath = tabMigrations[tab]
    if (newPath) {
      const newQuery = migrateQueryParams(to.query)
      return navigateTo({ path: newPath, query: newQuery }, { redirectCode: 302 })
    }

    // 未知のtab値の場合はデフォルトへ
    return navigateTo('/vouchers', { redirectCode: 302 })
  }

  // 2. ルートパスのデフォルトリダイレクト
  if (to.path === '/' && !to.query.tab) {
    return navigateTo('/vouchers', { redirectCode: 302 })
  }
})

Phase 2:ページ分離(1ページずつ)

全10ページを順次移行した。

pages/
├── index.vue                # / → /vouchers へリダイレクト
├── vouchers.vue             # /vouchers(読取一覧)
├── by-account.vue           # /by-account(科目別一覧)
├── duplicate.vue            # /duplicate(重複チェック)
├── result.vue               # /result(結果)
├── square.vue               # /square(Square明細)
├── creditcard.vue           # /creditcard(クレカ明細)
├── shiwake-rules.vue        # /shiwake-rules(勘定科目ビュー & ルール一覧ビュー)
├── journal.vue              # /journal(仕訳)
├── matrix.vue               # /matrix(月次推移表)
├── account-master.vue       # /account-master(勘定科目マスター)
└── document-types.vue       # /document-types(帳票設定)

移行順序は、最もシンプルな /matrix から開始し、最も複雑な /vouchers は中盤で移行した。

usePageQuerySync の導入

useTabQuerySync の後継として usePageQuerySync を作成した。

// composables/usePageQuerySync.ts
interface UsePageQuerySyncOptions {
  buildQuery: () => Record<string, string | undefined>
  onActivate?: () => void
}

export function usePageQuerySync(options: UsePageQuerySyncOptions) {
  const { buildQuery, onActivate } = options
  const router = useRouter()
  const route = useRoute()
  const isInitialized = ref(false)

  function updateQueryParams(usePush = false) {
    if (!import.meta.client || !isInitialized.value) return

    // 空文字・undefinedをフィルタリング
    const rawQuery = buildQuery()
    const query: Record<string, string> = {}
    for (const [key, value] of Object.entries(rawQuery)) {
      if (value !== undefined && value !== '') {
        query[key] = value
      }
    }

    // 重複ナビゲーション防止
    const currentQueryWithoutTab = /* ... */
    if (isSameQuery) return

    if (usePush) {
      router.push({ query })
    } else {
      router.replace({ query })
    }
  }

  function markInitialized() {
    isInitialized.value = true
    updateQueryParams(false) // 初回はreplace
    onActivate?.()
  }

  return { isInitialized, updateQueryParams, markInitialized }
}

ポイント

  • tab=xxx パラメータは不要(パス自体がタブを表す)
  • currentTab の inject も不要
  • パラメータ名のプレフィックスも不要(receiptYearyear

Phase 3:クリーンアップ(未実施)

以下はまだ実施していない。

  • 旧URL互換ミドルウェア有効化
  • index.vue の削除
  • useTabQuerySync の削除

核心的な気づき:push/replace問題はそもそも発生しない

移行中に最も重要な気づきを得た。

ページベースルーティングでは、push と replace の使い分け問題はそもそも発生しない。

従来の問題

クエリパラメータベースでは、タブ切り替えもフィルタ変更も同じページ内の状態変更だった。そのため「どちらを履歴に残すか」を明示的に判断する必要があった。

/?tab=receipt → /?tab=creditcard  → push(戻れるべき)
/?tab=receipt&year=2024 → /?tab=receipt&year=2025 → replace(戻らなくていい)

この判断を全ての箇所で正しく行うのは困難で、バグの原因になっていた。

ページベースでの解決

ページベースルーティングでは、タブ切り替え = ページ遷移になる。

/vouchers → /creditcard  → Vue Routerが自動でpush
/vouchers?year=2024 → /vouchers?year=2025 → replaceを使う(ページ内の状態変更)

タブ切り替え時の履歴管理は Vue Router が自動で行うため、開発者が push を明示的に呼ぶ必要がない。開発者が気にするのは「ページ内のフィルタ変更時に replace を使う」ことだけ。

結論:ブラウザ標準のAlt+矢印で十分

ページベースルーティングに移行すれば、Alt+矢印(ブラウザの戻る/進む)が自然に動作する。これまで実装していた履歴管理コードは 不要になるので削除できる

削除対象理由
composables/useTabQuerySync.tsページ遷移で履歴が自動管理されるため
router.afterEach / watch(() => route.fullPath, ...)戻る/進むでページが切り替わるので状態同期不要
switchTab内のrouter.push<NuxtLink>でのページ遷移に置き換え

テスト結果

Phase 2 完了時点

カテゴリパス失敗備考
全体498失敗は旧URLパターン期待のテスト

失敗したテストは旧URL形式(/?tab=xxx)を期待しているもの。Phase 3でレガシーリダイレクトを有効化すれば解決予定。

テストコードの移行方針

機能テストは新形式(/xxx)に統一し、レガシーリダイレクトテストは別ファイルで管理する方針とした。

// 新形式のテスト
test('読取一覧から科目別一覧へ遷移後、戻る/進むが動作する', async ({ page }) => {
  await page.goto('/vouchers')
  await page.click('[data-testid="tab-by-account"]')
  await expect(page).toHaveURL(/\/by-account/)
  // 戻る
  await page.goBack()
  await expect(page).toHaveURL(/\/vouchers/)
  // 進む
  await page.goForward()
  await expect(page).toHaveURL(/\/by-account/)
})

// レガシーリダイレクトテスト(別ファイル)
test('旧URL /?tab=receipt は /vouchers にリダイレクトされる', async ({ page }) => {
  await page.goto('/?tab=receipt')
  await expect(page).toHaveURL(/\/vouchers/)
})

学び

1. ブラウザ標準機能に任せる

複雑な履歴管理ロジックを自前で書くより、ブラウザ/フレームワークの標準機能に任せた方がシンプルで堅牢。

2. SPAの罠

SPAで全てをクエリパラメータで管理すると、結果的にMPAより複雑になることがある。Nuxtのようなフレームワークではページベースルーティングを素直に使うべき。

3. Codexレビューは計画段階で有効

実装前に計画ドキュメントをAIにレビューさせることで、矛盾点や不足を早期に発見できた。

関連ファイル

  • memo/2026-01-26/page-based-routing-refactor-plan.md - 計画ドキュメント
  • memo/2026-01-26/browser-history-bug-investigation.md - 履歴バグ調査
  • frontend/app/composables/usePageQuerySync.ts - 新しいcomposable
  • frontend/app/composables/useTabQuerySync.ts - 削除予定(Phase 3)