• #Vue.js
  • #パフォーマンス最適化
  • #ローカルステート
  • #tax-assistant
  • #クレカ明細
  • #UX改善
開発tax-assistantメモ

クレカ明細の確定処理パフォーマンス最適化

クレカ明細画面で「確定」ボタンを押すたびに、画面全体が一瞬カクつく問題が発生していた。265件のデータを扱う月だと体感でわかるレベルのガタつきで、確定作業を連続で行うユーザー体験を損ねていた。

原因の特定からローカルステート更新パターンへの切り替え、DBマイグレーション問題の発見と修正、他コンポーネントへの波及調査まで、一連の対応を記録する。

問題の症状

確定ボタンを押すと以下が起きていた。

  1. 右パネルの内容が一瞬空になる
  2. テーブルの選択行のハイライトが消える
  3. 0.5秒ほどして元に戻る

1件確定するたびにこのちらつきが発生するため、数十件を連続処理するときにストレスになっていた。

原因の特定

確定ボタンのハンドラを追いかけると、以下の処理フローになっていた。

// 問題のあったコード(簡略化)
const handleConfirm = async () => {
  confirming.value = true
  try {
    // 1. APIで確定ステータスに更新
    await api.confirmReceiptMatch(transaction.id)

    // 2. 全件再取得
    await loadData()

  } finally {
    confirming.value = false
  }
}

loadData() は全件をAPIから再取得して allTransactions を丸ごと差し替える関数。この差し替えが問題の根本原因だった。

連鎖的に起きていたこと

確定ボタン押下
  → API呼び出し(確定処理)
  → loadData() で全件再取得(265件)
  → allTransactions が新しい配列に差し替わる
  → watcher が発火
  → selectedIndex が -1 にリセットされる
  → 右パネルが「明細を選択してください」表示に切り替わる
  → 再取得完了後、selectedIndex が復元される
  → 右パネルが再描画される

selectedIndex-1 にリセットされるのは、allTransactions の watcher が配列の変更を検知して初期化処理を走らせていたため。配列の参照自体が変わるので、Vueのリアクティビティシステムが「データが変わった」と判断し、関連するすべてのwatcherとcomputedが再評価されていた。

解決: updateLocalStatus ヘルパーの導入

APIでの確定処理が成功した後に必要なのは、対象行の check_status フィールドだけの更新。265件を再取得する必要はない。

ヘルパー関数の作成

/**
 * ローカルの allTransactions 配列内の該当行のステータスだけを書き換える。
 * 配列の参照は変えず、オブジェクトのプロパティだけを更新するため、
 * watcherの不要な発火を防げる。
 */
const updateLocalStatus = (
  transactionId: number,
  newStatus: CheckStatus
) => {
  const target = allTransactions.value.find(t => t.id === transactionId)
  if (target) {
    target.check_status = newStatus
  }
}

確定ハンドラの書き換え

// 修正後
const handleConfirm = async () => {
  // 1. APIで確定ステータスに更新
  await api.confirmReceiptMatch(transaction.id)

  // 2. ローカルのステータスだけ更新(再取得しない)
  updateLocalStatus(transaction.id, 'receipt_confirmed')
}

何が変わったか

項目修正前修正後
API呼び出し確定API + 全件取得API確定APIのみ
データ更新配列丸ごと差し替え該当行のプロパティ変更
watcher発火allTransactionsのwatcherが発火発火しない
selectedIndex-1にリセット→復元変化なし
右パネル空→再描画変化なし
体感速度0.5秒のちらつき即座に反映

confirmingフラグの削除

修正前のコードでは confirming というrefを使って、確定処理中にUIの二重クリックを防止したり、ローディング表示を出したりしていた。

// 修正前: confirming フラグで処理中状態を管理
const confirming = ref(false)

const handleConfirm = async () => {
  confirming.value = true
  try {
    await api.confirmReceiptMatch(transaction.id)
    await loadData()
  } finally {
    confirming.value = false
  }
}

ローカルステート更新に切り替えたことで、loadData() の待ち時間がなくなった。APIの呼び出し自体は一瞬で終わるため、ローディング状態の管理が不要になり、confirming フラグを削除できた。コードがシンプルになった。

9月データで確定できない問題

パフォーマンス修正後のテスト中に、9月分のデータで確定ボタンが効かないケースが見つかった。

症状

  • 9月の一部の明細で「確定」を押しても、ステータスが receipt_confirmed に変わらない
  • APIレスポンス自体は200で返っている
  • 10月以降のデータでは問題なし

原因

DBを確認すると、9月のデータには古い ok ステータスが残っていた。以前のステータス体系では ok / ng の2値だったが、Phase 1で receipt_matched に移行した際、マイグレーションで既存の okreceipt_matched に変換するのを忘れていた。

確定APIは receipt_matched ステータスの明細に対してのみ receipt_confirmed への遷移を許可するバリデーションが入っていた。ok のままの明細はこのバリデーションに引っかかり、更新がスキップされていた。

修正

マイグレーションスクリプトを追加して、ok ステータスの残留データを receipt_matched に一括変換した。

-- 古い ok ステータスを receipt_matched に変換
UPDATE credit_card_transactions
SET check_status = 'receipt_matched'
WHERE check_status = 'ok';

これで9月データの確定も正常に動作するようになった。

教訓

ステータス値の名前変更や体系変更を行うときは、既存データのマイグレーションを忘れないこと。特に、バリデーションロジックが新しいステータス値を前提としている場合、古い値が残っているとサイレントに処理がスキップされる。エラーにならないため発見が遅れる。

他コンポーネントのアンチパターン調査

「ステータス更新のたびに全件再取得」というパターンが他の画面にもないか調査した。

調査対象

tax-assistantには同様のステータス更新UIを持つコンポーネントがいくつかある。

  • CreditCardTransactionsView: 今回修正した画面
  • DocumentTypesView: 書類タイプのマスタ管理画面
  • JournalRulesView: 仕訳ルール一覧画面

調査結果

DocumentTypesView にも同じパターンがあった。ステータス更新後に loadData() を呼んでいた。ただし、書類タイプは多くても10件程度のマスタデータなので、全件再取得してもパフォーマンスに影響はない。今回は修正対象外とした。

JournalRulesView はステータス更新のUIがなく、CRUD操作後にリスト再取得するパターン。これも件数が限定的(通常50件以下)で、問題になっていない。

判断基準

ローカルステート更新パターンに切り替えるべきかどうかの判断基準を整理した。

全件再取得をやめるべきケース:
  - データ件数が100件を超える可能性がある
  - 更新操作が連続で行われる(確定作業のような一括処理)
  - watcherが多く、データ差し替えの副作用が大きい

全件再取得のままでよいケース:
  - データ件数が少ない(数十件以下のマスタデータ)
  - 更新操作が単発(1回更新したら画面を離れる)
  - 他のユーザーが同時に更新する可能性がある(最新データとの同期が重要)

技術的な補足: Vueのリアクティビティとオブジェクトのプロパティ変更

今回のパターンが機能する背景にあるVueのリアクティビティの仕組みを補足する。

配列の参照変更 vs プロパティ変更

const items = ref([{ id: 1, status: 'pending' }])

// パターンA: 配列丸ごと差し替え(参照が変わる)
items.value = await fetchAll()
// → items を watch しているすべてのwatcherが発火

// パターンB: 要素のプロパティ変更(参照は変わらない)
items.value[0].status = 'confirmed'
// → items 自体のwatcherは発火しない(deep: true なら発火する)
// → テンプレート内で items[0].status を参照している箇所は再描画される

パターンBでは、items の配列参照は変わらないため、watch(items, ...) のようなwatcherは発火しない。一方で、Vueのテンプレートレンダリングは各プロパティ単位で依存関係を追跡しているため、status を表示しているコンポーネントだけが適切に再描画される。

これが「selectedIndexがリセットされない」「右パネルがちらつかない」理由。必要な箇所だけが更新され、不要な副作用が起きない。

deep watcherを使っている場合の注意

// deep: true の場合、プロパティ変更でも発火する
watch(items, handler, { deep: true })

もし allTransactions に対して deep: true のwatcherが設定されていたら、プロパティ変更でも発火してしまう。今回のケースでは deep: true は使っていなかったので問題なかった。既存のwatcherの設定を確認してから採用すること。

まとめ

  • 確定ボタンのカクつきは「全件再取得→watcher発火→selectedIndexリセット」の連鎖が原因
  • updateLocalStatus で該当行のプロパティだけを更新する方式に切り替え、体感速度を改善
  • confirming フラグが不要になり、コードもシンプルになった
  • 9月データの問題はDBに古い ok ステータスが残っていたことが原因。マイグレーションで receipt_matched に変換して解決
  • 他コンポーネントも調査したが、件数が少ないマスタデータのため修正不要と判断
  • 「全件再取得か、ローカル更新か」の判断基準は、データ件数、操作の連続性、watcherの副作用の3つで決める