【Vue/Nuxt】Propsの安全な扱い方：変更がアンチパターンとされる理由と対処法

Vue 3（およびNuxt 3）を使用していると、特に配列やオブジェクトを扱う場合、propsが原因で予期しない動作に遭遇することがあります。この問題は、JavaScriptの参照渡しの特性とVueのリアクティブシステムが組み合わさることで発生し、親コンポーネントの状態に影響を及ぼす可能性があります。この記事では、なぜpropsを変更することがアンチパターンとされるのか、その問題が発生し得るシナリオ、そして適切な対処法を解説します。

---

### なぜPropsの変更がアンチパターンなのか？

#### VueにおけるPropsの役割
`props`は親コンポーネントから子コンポーネントにデータを渡すための仕組みであり、**読み取り専用**として設計されています。これにより、一方向のデータフローが維持されます。`props`を直接変更することは、この原則を破り、以下のような問題を引き起こします：

1. **予期しない副作用**  
   子コンポーネントで`props`を変更すると、親コンポーネントの状態にも影響を与え、バグの原因になります。

2. **予測不可能な挙動**  
   Vueのリアクティブシステムは`props`が不変であると仮定しています。この仮定を破ると、予測不可能な動作につながります。

3. **開発モードでの警告**  
   Vue 3では、`props`を直接変更しようとすると警告が表示されます。

---

### Propsの静かな変更問題：Vueが警告を出さない場合

通常、Vueは`props`の直接変更を警告します。しかし、以下のようなケースではこの保護機能が回避されることがあります。

```javascript
const selectedItems = ref(props.selected); // propsをrefに代入
selectedItems.value.splice(index, 1);      // 配列を破壊的に変更
```

#### なぜこの問題が発生するのか？

- **JavaScriptの参照渡しの特性**  
  配列である`props.selected`を`ref`に代入すると、`ref`と`props`はメモリ内で同じ参照を共有します。そのため、`ref`の`value`を変更すると、元の`props`も変更されます。

- **Vueにおけるインスタンスの再生成**  
  親コンポーネントが`props`を更新すると、Vueのリアクティブシステムは新しいインスタンスを生成してその値を渡します。この結果、以前の参照が切れるため、`ref`が持つ値は親の最新の状態を反映しなくなります。この挙動により、開発者が意図しない一貫性の欠如が発生することがあります。

- **`ref`は初期値として動作**  
  `ref(props.selected)`は、`props.selected`の現在の値を初期値として`ref`に設定するだけであり、`props.selected`そのものをリアクティブにラップするわけではありません。このため、`props.selected`が後から更新されても`ref`はそれに追随しません。

- **警告が出ない理由**  
  この変更は`ref`を介して間接的に行われるため、Vueは`props`が直接変更されたとは認識せず、警告を出しません。

---

### この問題が発生するシナリオ

1. **`ref`や`reactive`でPropsをラップする場合**：

   ```javascript
   const selectedItems = ref(props.selected); // 同じ参照を保持
   selectedItems.value.push(newItem);         // `selectedItems`と`props.selected`両方が変更される
   ```

2. **オブジェクトや配列の参照渡し**：

   配列やオブジェクトとして渡された`props`は、明示的にコピーしない限り、参照が共有されます。

3. **破壊的な配列操作**：

   `push`、`splice`、`sort`などのメソッドは、配列を直接変更し、`ref`と親の`props`の両方に影響を与えます。

---

### 対策：Propsを安全に扱う方法

`props`を直接または間接的に変更しないために、データのコピー、リアクティブな参照の利用、不変性の保証といった方法があります。それぞれのベストプラクティスを順番に見ていきましょう。

特に、`props`の更新を反映させる場合は`toRef`が効果的です。また、変更不要なデータには`computed`を活用する方法が有効です。それぞれの状況に適した対策を順番に見ていきましょう。

#### 1. **浅いコピーを作成**
コンポーネント内で使用する前に、`props`のコピーを作成します。

```javascript
const selectedItems = ref([...props.selected]); // 浅いコピー
```

ネストされたオブジェクトや配列の場合は、深いコピーを使用します：

```javascript
const selectedItems = ref(JSON.parse(JSON.stringify(props.selected))); // 深いコピー
```

#### 2. **`structuredClone`を使用**
最新のJavaScriptでは、`structuredClone`を使用して効率的な深いコピーが可能です。

```javascript
const selectedItems = ref(structuredClone(props.selected));
```

これは`Date`オブジェクトや`TypedArray`のような複雑な構造も処理できます。

#### 3. **不変の派生データに`computed`を使用**
変更が必要ない場合、`computed`プロパティを使用して派生データを扱います。

```javascript
import { computed } from 'vue';

const selectedItems = computed(() => [...props.selected]);
```

#### 4. **`toRef`を使用してリアクティブにする**
もし`props`の更新を反映させたい場合には、`toRef`を使用することで、`props.selected`をリアクティブな参照として扱えます。

```javascript
import { toRef } from 'vue';

const selectedItems = toRef(props, 'selected');
```

この方法では、親コンポーネントで`props.selected`が変更されても、子コンポーネントの`selectedItems`が自動的に更新されます。

#### 5. **Vueの`readonly`ユーティリティを活用**
`props`を`readonly`でラップして、不変性を保証します。

```javascript
import { readonly } from 'vue';

const selectedItems = ref(readonly(props.selected));
```

これにより、`selectedItems.value`を変更しようとするとエラーが発生します。

#### 6. **Propsを直接`ref`にラップしない**
`ref`でラップする代わりに、ローカル状態を別途用意し、コピーを初期化します。

```javascript
const selectedItems = ref(props.selected ? [...props.selected] : []);
```

---

### Nuxt 4ではどうなるのか？

現在のところ、Nuxt 4自体にこの問題を直接解決するような変更はありません。ただし、NuxtはVueをベースに構築されているため、将来的にVueのリアクティブシステムに改善や変更が加えられれば、それがNuxtにも反映されるでしょう。

Nuxtエコシステムは、自動インポートやTypeScriptの改善、パフォーマンス向上など、開発体験を向上させることに重点を置いています。これらのツールは直接`props`の変更を防ぐものではありませんが、Vueの設計原則に従うことでこの問題を回避できるよう促しています。

---

### 結論

Vueにおいて、`props`を直接または間接的に変更することは、予測不能な動作やデバッグ困難なバグにつながるアンチパターンです。`props`の特性やVueのリアクティブシステムを正しく理解したうえで、最適な対処法を選択することが重要です。この記事で紹介したベストプラクティスを参考に、よりクリーンで保守性の高いコードを目指しましょう。

- `props`の浅いコピーまたは深いコピーを作成する
- `structuredClone`や`computed`を活用する
- `readonly`で不変性を保証する

これらのベストプラクティスを採用することで、よりクリーンで予測可能なコードベースを維持し、コンポーネントの保守性を向上させることができます。