• #Vue.js
  • #Nuxt 3
  • #リファクタリング
  • #URL状態管理
  • #composable
開発tax-assistantメモ

Vue.js タブ分割とURLクエリパラメータによる状態管理

背景と課題

税務アシスタントアプリの index.vue が700行以上に肥大化していた。6つのタブ(読取一覧、科目別一覧、結果、重複チェック、月次推移表、Square明細)の全ロジックが1ファイルに集中し、以下の問題が発生していた。

発見された問題点

重要度問題詳細
重複チェック→レシート移動が失敗batch_id引数を無視。バッチ/年月を跨ぐと遷移できない
結果タブとCSVエクスポートの対象不一致結果タブは全バッチ表示だが、CSVはcurrentBatchIdのみ対象
タブ間で状態が共有されているselectedYear/selectedMonthを読取一覧と結果タブで共有(副作用あり)

根本原因は「タブごとに状態が分離されていないため、あるタブでの操作が別タブに影響する」こと。

解決策:タブ分割とcomposable抽出

ディレクトリ構成

既存の XXXView.vue パターンを活かし、新規作成は最小限に抑えた。

components/
  duplicate/DuplicateView.vue     # 既存: 重複チェックタブ
  matrix/MatrixView.vue           # 既存: 月次推移表タブ
  miller/MillerColumnsView.vue    # 既存: 科目別一覧タブ
  square/SquareMatchingView.vue   # 既存: Square明細タブ
  tabs/ResultTab.vue              # 新規: 結果タブ(年月フィルタ独立)
  tabs/ReceiptTab.vue             # 新規: 読取一覧タブ

composables/
  useBatches.ts                   # 既存
  useReceipts.ts                  # 既存
  useResultItems.ts               # 新規: 結果タブ専用

pages/
  index.vue                       # タブ切り替え + グローバル状態のみ

責務の分離

コンポーネント責務使用するcomposable
index.vueタブ切り替え、帳票種別、バッチ一覧のprovideuseBatches
tabs/ReceiptTab.vue読取一覧タブ全体useReceipts
tabs/ResultTab.vue全データの一覧表示、年月フィルタ(独立)useResultItems(新規)
XXXView.vueそれぞれのタブ固有ロジック専用composable

useResultItems composableの実装

結果タブ専用のcomposableを新規作成。読取一覧タブのselectedYear/selectedMonthとは完全に独立させた。

// composables/useResultItems.ts
interface UseResultItemsOptions {
  documentType: Ref<string>
}

export function useResultItems(options: UseResultItemsOptions) {
  const api = useApi()
  const { documentType } = options

  // 全データ(ResultTab専用)
  const allItems = ref<Receipt[]>([])
  const loading = ref(false)

  // タブ固有のフィルター状態(index.vueのselectedYear/selectedMonthとは独立)
  const selectedYear = ref<string | null>(null)
  const selectedMonth = ref<string | null>(null)

  // 帳票種別でフィルタリングした全アイテム(日付順ソート)
  const itemsByDocType = computed(() =>
    allItems.value
      .filter(item => (item.document_type || 'receipt') === documentType.value)
      .sort((a, b) => {
        const dateA = (a['日付'] as string) || ''
        const dateB = (b['日付'] as string) || ''
        return dateA.localeCompare(dateB)
      })
  )

  // 年リスト(昇順)
  const years = computed(() => {
    const yearSet = new Set<string>()
    itemsByDocType.value.forEach((item) => {
      const year = parseYear(item['日付'] as string | undefined)
      if (year) yearSet.add(year)
    })
    return Array.from(yearSet).sort()
  })

  // 年ごとの統計
  const yearStats = computed(() => {
    const stats: Record<string, { total: number, confirmed: number }> = {}
    itemsByDocType.value.forEach((item) => {
      const year = parseYear(item['日付'] as string | undefined)
      if (year) {
        if (!stats[year]) stats[year] = { total: 0, confirmed: 0 }
        stats[year].total++
        if (item.confirmed) stats[year].confirmed++
      }
    })
    return stats
  })

  // 選択年内の月リスト(昇順)
  const months = computed(() => {
    if (!selectedYear.value) return []
    const monthSet = new Set<string>()
    itemsByDocType.value.forEach((item) => {
      const parsed = parseYearMonth(item['日付'] as string | undefined)
      if (parsed && parsed.year === selectedYear.value) {
        monthSet.add(parsed.month)
      }
    })
    return Array.from(monthSet).sort()
  })

  // 年月でフィルタリングしたアイテム(表示用)
  const items = computed(() => {
    let filtered = itemsByDocType.value
    if (selectedYear.value) {
      filtered = filtered.filter((item) => {
        const year = parseYear(item['日付'] as string | undefined)
        return year === selectedYear.value
      })
    }
    if (selectedMonth.value) {
      filtered = filtered.filter((item) => {
        const parsed = parseYearMonth(item['日付'] as string | undefined)
        return parsed && parsed.month === selectedMonth.value
      })
    }
    return filtered
  })

  // 年選択
  function selectYear(year: string | null) {
    selectedYear.value = year
    if (year) {
      // 年が変わったら最初の月を自動選択
      nextTick(() => {
        const availableMonths = months.value
        selectedMonth.value = availableMonths.length > 0 ? availableMonths[0]! : null
      })
    } else {
      selectedMonth.value = null
    }
  }

  return {
    allItems,
    loading,
    selectedYear,
    selectedMonth,
    years,
    yearStats,
    months,
    monthStats,
    items,
    loadAllItems,
    selectYear,
    selectMonth,
  }
}

グローバル状態の管理(provide/inject)

Piniaは使用せず、provide/inject パターンで実装。理由は以下の通り。

  • 既存コードがprovide/injectパターン
  • タブ間共有は2-3個の状態のみで、Piniaほどの機能は不要
  • 追加の依存・学習コストを避けたい

index.vueでprovideする状態

// index.vue
provide('documentTypes', documentTypes)
provide('currentDocumentType', currentDocumentType)
provide('selectDocumentType', selectDocumentType)
provide('batches', batches)
provide('loadBatches', loadBatches)
provide('currentTab', currentTab)
provide('goToReceipt', handleGoToReceipt)

設計ルール:タブ固有状態の共有禁止

タブ固有の状態(フィルター、選択状態など)は必ずタブ内で完結させる。provide/inject や Pinia に移しても、タブ間で共有しない。違反すると「あるタブでの操作が別タブに影響する」問題が再発する。

URLクエリパラメータによる状態管理

タブ分割により各タブが独自の状態を持つようになったため、クエリパラメータの実装が安全に行えるようになった。

解決するユーザビリティ問題

  • 重複チェック → レシート遷移後、ブラウザ「戻る」で元の画面に戻れない
  • URLを共有しても同じ画面を再現できない

対応パラメータ

パラメータ値の例用途
tabreceipt, result, duplicate, matrix, miller, square表示中のタブ
docTypereceipt, sales_slip帳票種別
batch20260114_123456バッチID(読取一覧タブ)
receiptYear2024読取一覧タブの年フィルター
receiptMonth02読取一覧タブの月フィルター
receiptFile20250121_0327_0001.jpg読取一覧タブの選択ファイル
resultYear2024結果タブの年フィルター
resultMonth02結果タブの月フィルター
dupYear2024重複チェックの年フィルター
dupMonth02重複チェックの月フィルター

注意: 年月はタブごとに別キーにすることで「タブ固有状態の共有禁止ルール」を遵守

URL例

# 読取一覧タブ(特定ファイル指定)
/?batch=20260109_144208&receiptYear=2024&receiptMonth=02&receiptFile=20250121_0327_0001.jpg

# 重複チェックタブ(2024年2月)
/?tab=duplicate&dupYear=2024&dupMonth=02

# 結果タブ(売上伝票、2024年1月)
/?tab=result&docType=sales_slip&resultYear=2024&resultMonth=01

実装詳細:履歴管理の使い分け

router.replace() vs router.push()

メソッド用途使用箇所
router.replace()履歴を汚さない通常の状態変更(年月選択、タブ内操作)
router.push()履歴に残す意図的な遷移(goToReceipt)

updateQueryParams()の実装

// 履歴を汚さないURL更新
function updateQueryParams() {
  if (!import.meta.client || !isInitialized.value) return

  // このタブがアクティブな時だけURLを更新(他タブの履歴を汚染しない)
  if (currentTab?.value !== 'result') return

  // このタブ専用のクエリを構築(他タブのパラメータは含めない)
  const query: Record<string, string> = {
    tab: 'result',
    docType: currentDocumentType.value,
  }

  if (selectedYear.value) {
    query.resultYear = selectedYear.value
  }
  if (selectedMonth.value) {
    query.resultMonth = selectedMonth.value
  }

  router.replace({ query })
}

ブラウザ戻る/進む対応

route.fullPath を監視してURLの変更を検知し、内部状態を同期する。

// index.vue
watch(() => route.fullPath, () => {
  if (!import.meta.client || !isInitialized.value) return

  const query = route.query
  const validTabs = ['receipt', 'result', 'duplicate', 'matrix', 'miller', 'square', 'creditcard']

  // タブの同期
  const queryTab = query.tab as string | undefined
  if (queryTab && validTabs.includes(queryTab) && currentTab.value !== queryTab) {
    currentTab.value = queryTab as typeof currentTab.value
  } else if (!queryTab && currentTab.value !== 'receipt') {
    currentTab.value = 'receipt'
  }

  // 帳票種別の同期(URLに明示的にdocTypeがある場合のみ変更)
  const queryDocType = query.docType as string | undefined
  if (queryDocType && documentTypes.some(t => t.id === queryDocType) && currentDocumentType.value !== queryDocType) {
    currentDocumentType.value = queryDocType
  }
})

DuplicateViewからの遷移・戻り機能

重複チェックタブからレシートに遷移する際、goToReceipt関数を使用する。この関数はindex.vueでprovideされている。

goToReceipt関数の実装

// index.vue
function handleGoToReceipt(fileName: string, batchId: string) {
  const key = `${batchId}::${fileName}`

  // 現在のURLを履歴に保存してから遷移(戻るボタンで戻れるように)
  if (import.meta.client) {
    const currentUrl = new URL(window.location.href)
    window.history.pushState({ ...window.history.state }, '', currentUrl.toString())
  }

  currentTab.value = 'receipt'
  if (import.meta.client) {
    localStorage.setItem('ocr_checker_current_tab', 'receipt')
  }

  // nextTickでReceiptTabがアクティブになってからselectByKeyを呼ぶ
  nextTick(() => {
    if (receiptTabRef.value) {
      receiptTabRef.value.goToReceiptByKey(key)
    }
  })
}

provide('goToReceipt', handleGoToReceipt)

DuplicateViewでの使用

// DuplicateView.vue
const goToReceiptFn = inject<(fileName: string, batchId: string) => void>('goToReceipt')!

function handleItemClick(item: DuplicateItem) {
  goToReceiptFn(item.file_name, item.batch_id)
}

localStorageとURLパラメータの役割分担

両方を使用することで、異なるユースケースに対応する。

用途localStorageクエリパラメータ
通常のリロード復元される復元される(優先)
ブラウザ戻る/進む対応不可対応
URL共有共有不可共有可能
タブクリックで直接移動時の復元対応非対応

優先順位

クエリパラメータ > localStorage > デフォルト値

ユースケース別の動作

1. 戻るボタン派(履歴を辿る)

Square明細(2024/02) → 結果タブ → 戻るボタン → Square明細(2024/02)に戻る

URLクエリパラメータが履歴に残っているため、戻るボタンで元の状態に戻れる。

2. タブクリック派(直接移動)

Square明細(2024/02) → 結果タブ → Square明細タブをクリック → Square明細(2024/02)に戻る

localStorageに「前回の位置」が保存されているため、タブをクリックしても元の位置に復元される。

発生したバグと修正

1. 履歴汚染の問題

問題: 非アクティブなタブ(v-showで隠れているだけ)が、バックグラウンドでupdateQueryParams()を呼び出し続け、URLに不要なパラメータが追加されていた。

修正: 各タブコンポーネントでcurrentTabをinjectし、自分のタブがアクティブな時だけURLを更新するようにした。

const currentTab = inject<Ref<string>>('currentTab')

function updateQueryParams() {
  if (!import.meta.client || !isInitialized.value) return
  if (currentTab?.value !== 'result') return  // 追加
  // ...
}

2. Alt+矢印キーが効かない問題

問題: ブラウザの戻る/進むショートカット(Alt+Left/Right)が効かなかった。

原因: キーボードハンドラが矢印キーをe.preventDefault()でキャンセルしており、Alt修飾キーのチェックがなかった。

修正: Altキーが押されている場合はブラウザに処理を委譲。

// 修正後
if (e.altKey) return  // Alt+矢印はブラウザの戻る/進むなので処理しない

if (e.key === 'ArrowLeft') {
  e.preventDefault()
  goPrev()
}

3. タブ切り替えが1回目で効かない問題

問題: 読取一覧→科目別一覧など、タブを切り替える際に1回目のクリックでは切り替わらず、2回目で切り替わる。

原因: タイミング競合。switchTab()実行後、タブコンポーネントのwatchがupdateQueryParams()を呼び、その結果index.vueのroute.fullPathwatchが発火してタブを元に戻そうとしていた。

修正: タブアクティブ時のupdateQueryParams()nextTick()で遅延実行。

watch(() => currentTab?.value, (newTab) => {
  if (newTab === 'result' && isInitialized.value) {
    nextTick(() => {
      updateQueryParams()
    })
  }
})

リファクタリング効果

指標BeforeAfter
index.vue 行数859行356行
タブ間状態共有あり(バグの原因)なし
ブラウザ戻る対応なしあり
URL共有不可可能

検証項目

項目手順期待結果
読取一覧タブの独立性結果タブで年月を変更 → 読取一覧タブに戻る読取一覧タブの年月フィルタは変更されていない
帳票種別の共有読取一覧タブで「売上伝票」を選択 → 結果タブに移動結果タブも「売上伝票」が選択されている
重複チェック→レシート移動重複チェックタブで異なるバッチのレシートをクリック読取一覧タブに遷移し、該当レシートが表示される
戻るボタン重複チェック→レシート遷移後、Alt+←重複チェックタブに戻る

まとめ

700行超のindex.vueを356行に削減し、タブごとに状態を分離することで保守性が向上した。URLクエリパラメータによる状態管理を追加し、ブラウザの戻る/進むボタン対応とURL共有機能を実現した。

設計上の重要なポイントは「タブ固有状態の共有禁止ルール」で、これを守ることでタブ間の副作用を防止できる。また、router.replace()router.push()の使い分けにより、履歴の汚染を防ぎつつ必要な遷移は履歴に残すことができた。