検索条件の状態管理にハマった話

## 背景と前提

ある既存の検索画面のUIを大きく改修するプロジェクトに参加しました。

この画面では、商品データなどを検索・可視化するための条件入力があり、以下のような構成で作っていっていました。

### コンポーネント構成（Vue 3 / Composition API）

- 親コンポーネント（`index.vue`）  
  - 検索APIの呼び出し・状態管理を担当
- 子コンポーネント（検索フォーム）  
  - 入力UIをまとめたフォームコンポーネント
- 孫コンポーネント（基本条件・詳細条件・フィルターなど）  
  - 実際の入力パーツ

実際私が改修したサービスとは異なりますが、例えがないと困るので、
恐縮ですが最近使用させて頂いてるアットホームの画面で例えます。
（私的に最近引っ越しを考えていて、参考にさせていただきました、）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621281/4d0f148d-049e-4747-9de7-984df6ee3085.png)

ここでいうとこのようなイメージです。

親コンポーネント→左の枠すべて
子コンポーネント→地図から探す、絞り込み条件を指定する　などのウィンドウ
孫コンポーネント→ウィンドウ内の、都道府県、市区郡、物件種別、賃料などの小見出し


### 検索トリガーの種類

検索は複数の契機で発火しうるように設計されていました：

- 検索ボタンの押下  
- タブ切り替え時の自動検索  
- 保存済み条件の適用  
- 特定タブでの自動計算による条件上書き  
- URLパラメータからの条件復元（シェアURL）


一見、よくある検索画面に見えますが、  
UI改修を進めていく中で、状態がズレる・検索が勝手に発火する・デバッグが困難になる…といったトラブルが頻発しました。

---

## なぜ検索条件の管理はこんなに難しいのか？💥

このプロジェクトを通じて見えてきた、本質的な課題は以下の3つです。

---

### 1. 状態に型がなく、構造と仕様の見通しが悪かった

検索パラメータに相当する型定義が存在せず、どんなプロパティがあるのか、どのUIやAPIで使われるのかが分かりませんでした。  

コードでいうとこんな感じ。
初めて触る人は、検索パラメータが何を持っているのか理解できません。

```ts
const state = reactive({
  condition: {}
})
```
そのため、以下のようなことが頻繁に起こりました：

- 値の存在確認や利用箇所の特定に時間がかかる  
- 仕様変更や追加要件が入るたびにバグが増える  
- 保存条件やURLのやり取りで予期しないデータが流れる  

型がないことで、仕様と実装の境界が曖昧になり、特に新しい開発者にとっては構造を理解するのが非常に困難な状態でした。

---

---

### 2. 状態の責務と所在が一貫していなかった

検索条件という中心的な状態を、本来責任を持つべき親ではなく、子や孫コンポーネントが独自に `reactive` なローカル状態として保持していました。  

#### 親コンポーネント（index.vue）

```ts
const state = reactive({
  condition: {}
})

const onSearch = () => {
  fetchData(state.condition)
}
```

#### 子コンポーネント（SearchForm.vue）

```ts
const props = defineProps(['condition'])

// ローカルでreactive化して使う
const localCondition = reactive(clonedeep(props.condition))

// 外からの更新をwatchで検知して同期（手動でマージ）
watch(() => props.condition, (newVal) => {
  Object.assign(localCondition, newVal)
})
```

このコードでは、次のような深刻な問題が潜んでいます：

- `condition` に何のプロパティが入っているか分からない（型定義なし）
- `localCondition` に好き放題プロパティを追加・更新できる（安全性なし）
- UIに何がバインドされているのか、コードからは追えない

この状態の分散により、次のような問題が発生しました：

- 親と子で状態がズレる  
- 編集中の条件が外部から上書きされる  
- どの条件が検索に使われたか分からない  

これを補うために `watch` を使って親→子へ状態を流したりしていました。

が、それが逆に副作用（意図しない再検索や上書き）を生み、状態の制御が破綻しやすくなっていました。



### 3. 状態変化のトリガーが統一されておらず、制御できなかった

検索を発火させるタイミング（検索ボタン、保存条件、URL、タブ、自動計算）がバラバラで、  
「どこからの変更なのか」「この条件で検索してよいのか」が明示的に管理されていませんでした。

検索は複数の契機から発火するようになっており、それぞれで状態の扱い方が異なります。

| トリガー | 操作主体 | タイミング |
|----------|-----------|------------|
| 検索ボタン押下 | ユーザー | 明示的な操作時 |
| 保存条件の適用 | ユーザー or システム | 条件を選択した直後 |
| URLからの条件復元 | システム | 初回表示時 or リンク遷移時 |
| タブ切り替え | ユーザー | タブを切り替えた瞬間 |
| 自動計算による条件生成 | システム（内部ロジック） | 特定の条件に該当したとき |


その結果：

- 意図しない検索が勝手に発火  
- 編集中の状態が壊れる  
- 状態の変更理由が分からず、`console.log` 頼りのデバッグに  


検索は複数の契機から発火するようになっており、それぞれで状態の扱い方が異なります。




状態が変わった理由と、その結果としてAPIが呼ばれた理由が追えず、  
**「なぜそうなったのか」より「今なにが起きてるのか」を調べることに多くの時間を使う羽目になっていました。**

---

## どうすればよかったの？

ここからは、こうした問題にどうアプローチしようとしているか、具体的な改善案を考えていきます。  
いずれも「UIや機能は変えず、状態管理の構造だけをまず整える」ことを目的としています。

---

### 1. 型定義を整備し、仕様と構造を一致させる

```ts
type SearchCondition = {
  keyword: string
  genre: string | null
  filter: {
    maker: string
    priceRange?: [number, number]
  }
}
```

- フォームやAPIとのインターフェースに使う型を1つに統一  
- コメントやJSDocで各プロパティの用途も記載し、**型＝仕様**の状態にする  

これによって、保存条件・シェアURL・初期状態なども含めて、  
**「状態の全体像」がひと目でわかるようになります。**

---

### 2. 子・孫コンポーネントは状態を持たず、編集UIとして責務を限定する

```vue
<SearchForm
  :condition="editingCondition"
  @update:condition="(val) => editingCondition = val"
/>
```

- 子・孫は状態を持たず、Propsを受け取って編集UIに徹する  
- 状態の本体は親に一元化し、責務の混乱を避ける  

これにより、どこを見れば状態がわかるか、誰が値を持っているかが明確になります。


---

### 3. 状態を「編集中」と「検索実行用」に分離する

```ts
const editingCondition = reactive<SearchCondition>()
const appliedCondition = ref<SearchCondition>()
```

- 編集中は `editingCondition` を使ってUIにバインド  
- 検索を実行するタイミングで `appliedCondition` にコピーし、APIへ送る 

これを親側で持ちます。
つまり、
ボタンを押して検索のような明示的な変更も
タブ押下で画面が切り替わるときの自動的な変更も
すべて編集用の状態`editingCondition`を経由させます。

そうすることで、親ー子ー孫の状態を一元管理でき、検索結果とUIが必ずStateが一致します。

これにより、「UIで見えている状態」と「実際に使われた状態」を切り分けられ、両者のズレが起こりにくくなります。

---




## おわりに：仕様が複雑なのではなく、状態の管理が破綻していた

タブ切り替え時に検索する、保存条件を適用する、URLから初期状態を復元する

こういった仕様自体は一般的なUIであり、特別に複雑というわけではありません。

また実際に開発を進めていざテストで触ってもらうとき、プロダクトに詳しい方が既存の機能をどんどん発見し、追加していかれるものです。

それは仕方ないですし、開発担当者がすべて網羅することは不可能です。


なので、問題だったのは、それらを支える**状態管理の構造が破綻していたこと**です。

・責務が曖昧  
・構造が見えない  
・変化が追えない  

この3拍子が揃うと、検索条件のような「見えない状態」を扱う処理では、一気にバグの温床になります。

今後の改善はまだ続きますが、まずは「構造と流れを正す」ことを最優先に取り組んでい来たいと思います。  

---

## 最後に

似たような悩みを抱える人の参考になれば嬉しいです。  
他にも「ここどうしてる？」「こうやってるよ」みたいなアイデアがあればぜひ教えてください！🙌

## おまけ：生成AIについて思ったこと

今回の改修を通して、あらためて実感したことがあります。

**それは既存機能の改修やバグ改修では、今の生成AIのレベルだとまだ使えなかったことです。**

CursorなどのAIエディタでもだいぶ依存関係をつかめるようになっていは来ているものの、それだけは完結しません。

特に型が無いシステムはAIが読み取れないです。

世の中で「AIでエンジニアがいらなくなる」という話もよく聞きます。  
確かに、何もないところにコードを生み出す「新規開発」は、AIが得意な領域かもしれません。

ですが現実には、
**今あるシステムをどう活かすか、どう改善するか**という“改修”のほうが圧倒的に多いです。  

そしてこの「既存機能の改修」には、

・過去の設計意図、
・積み重なった仕様、
・他機能との依存関係

といった、**コンテキストの読み解きが不可欠**です。
（やりたいかどうかは別の議論になるかもですが、）

AIはその背景まで理解してくれるわけではないです。
規模が大きいシステムになってくると、**脳にチップでも入れない限りコンテキストをすべて落とし込むほうが時間がかかります。**

そうなるまでは、人間の想像力と判断力がより求められる場面だと感じました。

新規開発はAIが得意なのは間違いないと思います。  
でも、複雑な既存システムを理解し、整理し、拡張し、ビジネスにつなげていく役割は、きっとこれからも人間に必要とされ続けると思いました。

だからこそ私は、こうした改修の現場にこれからも価値を出していけるよう、学び続けていきたいと思います☺️