markdown

はじめに (対象読者・この記事でわかること)

この記事は、Vue 3 でコンポーネント間のデータ受け渡しに悩んでいるフロントエンドエンジニア、特に二次元配列(行列データ)を扱う必要がある方を対象としています。
Vue の基本的な使い方は理解しているが、「子コンポーネントに二次元配列を Props として渡す方法がわからない」という具体的な課題を持つ方が対象です。

この記事を読むことで、以下ができるようになります。

  1. Vue 3 の props で二次元配列を型安全に受け取る書き方
  2. 親コンポーネントから子コンポーネントへリアクティブに二次元配列を渡すベストプラクティス
  3. 配列の更新(追加・削除・入れ替え)を子コンポーネント側で正しく反映させるテクニック

背景として、実務でテーブルデータや座標情報を子コンポーネントに渡すケースが増えており、型情報が失われやすい点や、リアクティブ更新が期待通りに動かないケースが頻繁に報告されています。本稿はそうした実務的な悩みを解決することを目的に執筆しました。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • HTML と CSS の基本的な知識
  • JavaScript(ES6 以降)の基礎文法とモジュールシステム
  • Vue 3 のコンポーネント作成、setup 関数、Composition API の基本的な使い方

Vue 3 に二次元配列を渡す背景と全体像

Vue では、親コンポーネントから子コンポーネントへデータを渡す際に props を使用します。単純な一次元配列やプリミティブ型はそのまま渡せますが、二次元配列は「配列の中に配列が入る」構造であり、以下のような注意点があります。

  1. リアクティビティの粒度
    Vue のリアクティビティは「オブジェクトのプロパティ」や「配列のインデックス」単位で追跡します。二次元配列の内部要素を直接変更した場合、Vue が変更を検知できないケースがあります。Vue.set(もしくは reactive / ref の組み合わせ)で明示的に更新を通知する必要があります。

  2. 型安全
    大規模プロジェクトでは TypeScript が使用されることが多く、props の型定義で二次元配列を正しく表現しないとコンパイルエラーやランタイムバグの原因になります。PropType<Array<Array<...>>> を利用して明示的に型を指定します。

  3. パフォーマンス
    二次元配列はサイズが大きくなると更新コストが増大します。Vue 3 では shallowRef を使うことで「浅いリアクティビティ」のみを追跡し、内部配列の再描画を最小限に抑えるテクニックがあります。

以上を踏まえて、実装例とともに「安全に、かつ効率的に二次元配列を渡す方法」を段階的に解説していきます。

二次元配列を Props として渡す具体的な実装手順

1. 親コンポーネントで二次元配列を定義し、reactive にする

Vue
<script setup lang="ts"> import { reactive } from 'vue'; import ChildTable from './ChildTable.vue'; // 例: 5 行 3 列の数値マトリクス const matrix = reactive<number[][]>([ [1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12], [13, 14, 15] ]); // データ更新用メソッド(デモ用) function addRow() { const newRow = matrix[0].map(() => 0); // 同じ列数で初期化 matrix.push(newRow); } </script> <template> <div> <h2>親コンポーネント</h2> <button @click="addRow">行を追加</button> <ChildTable :grid="matrix" /> </div> </template>
  • reactive に包むことで、配列全体がリアクティブになり、内部要素の変更も Vue が検知できるようになります。
  • matrix の型は number[][] と明示的に書くことで、IDE の補完が効きやすくなります。

2. 子コンポーネントで Props の型を定義し、shallowRef で受け取る

Vue
<script setup lang="ts"> import { defineProps, shallowRef, watch } from 'vue'; import type { PropType } from 'vue'; // Props の型定義 const props = defineProps({ grid: { type: Array as PropType<number[][]>, required: true } }); // shallowRef で受け取る(内部変更は親側で行う想定) const localGrid = shallowRef(props.grid); // 親から渡された Props が変化したときにローカルに同期 watch( () => props.grid, (newVal) => { localGrid.value = newVal; }, { deep: true } ); </script> <template> <div> <h3>子コンポーネント(テーブル表示)</h3> <table border="1" cellpadding="4"> <tr v-for="(row, rowIndex) in localGrid" :key="rowIndex"> <td v-for="(cell, colIndex) in row" :key="colIndex">{{ cell }}</td> </tr> </table> </div> </template> <style scoped> table { border-collapse: collapse; } td { text-align: center; width: 40px; } </style>
  • definePropsgrid の型を number[][] と明示。
  • shallowRef を使うと、grid 全体が変更されたときだけ再描画が走り、内部のセルを書き換えても無駄な再計算が抑えられます。
  • watchdeep: true を付けることで、配列内部が深く変更されたときにも localGrid が最新の参照に追従します。

3. 子コンポーネント側でセルを書き換えるケース(双方向バインディング)

子コンポーネントでセル編集を許可したい場合、子から親へイベントを発火し、親側で配列を更新するのがベストプラクティスです。

Vue
<!-- 子コンポーネント側の追加コード --> <script setup lang="ts"> import { defineEmits } from 'vue'; const emit = defineEmits(['update:grid']); function onCellInput(rowIdx: number, colIdx: number, event: Event) { const target = event.target as HTMLInputElement; const newValue = Number(target.value); // 親へ変更を通知 emit('update:grid', { rowIdx, colIdx, newValue }); } </script> <template> <table border="1" cellpadding="4"> <tr v-for="(row, rowIndex) in localGrid" :key="rowIndex"> <td v-for="(cell, colIndex) in row" :key="colIndex"> <input type="number" :value="cell" @input="onCellInput(rowIndex, colIndex, $event)" style="width: 40px;" /> </td> </tr> </table> </template>

親側で受け取る:

Vue
<ChildTable :grid="matrix" @update:grid="({ rowIdx, colIdx, newValue }) => { matrix[rowIdx][colIdx] = newValue; }" />
  • emit('update:grid', ...) の命名は Vue が自動的に v-model:grid と同等の双方向バインディングを提供できる形に合わせています。
  • これにより、子コンポーネントからの変更がリアクティブに親に反映され、他の子コンポーネントや UI も即座に更新されます。

4. ハマりやすいポイントとエラー対策

・「配列が更新されても子コンポーネントが再描画されない」

原因: reactive で包んだ配列の内部要素を直接代入しただけ(例: matrix[0][0] = 5;)。Vue は深い変更を検知しにくい。
対策: matrix[0].splice(0, 1, 5); のように配列操作メソッドを使うか、set/splice を利用する。

・Prop の型エラー(PropType<Array<Array<number>>> が認識されない)

原因: TypeScript の型推論が正しく働かないケース。
対策: as PropType<number[][]> と明示的にキャストし、required: true と併せて default: () => [] のようにデフォルトを提供する。

・パフォーマンス低下(大規模マトリクスで毎フレーム描画が走る)

原因: shallowRef を使わず ref で全体をリアクティブにすると、内部変更でも再描画が走る。
対策: shallowRefwatch の組み合わせで、必要なときだけ 再描画させる。さらに、v-forkey に行インデックスだけでなくユニーク ID を付与すると、Vue の diff が効率化されます。

5. 発展的テクニック:computed と immutable パターン

大規模アプリでは 不変(immutable)データ構造 を採用し、変更時に新しい配列インスタンスを生成するのが推奨されます。computed とスプレッド構文でコピーを作り、React のように差分更新を行うことで、デバッグが容易になり、Vue のリアクティビティも最適化できます。

Ts
import { computed } from 'vue'; const immutableMatrix = computed(() => matrix.map(row => [...row])); // 子コンポーネントへは immutableMatrix を渡す

このやり方は、変更履歴のトレースや Undo/Redo 機能 を実装したい場合に非常に有効です。

まとめ

本記事では、Vue 3 に二次元配列を Props として渡す際の概念・注意点・実装手順を体系的に解説しました。

  • リアクティビティ確保reactiveshallowRef の組み合わせで内部変更を正しく検知
  • 型安全PropType<number[][]> で明示的に型定義し、TypeScript の恩恵を最大化
  • 双方向更新emit('update:grid')v-model 互換で子から親への変更をシームレスに伝搬

これらを実践すれば、二次元配列を扱うテーブルやグリッド系コンポーネントの開発が格段に楽になります。次回は、Vue Router と組み合わせたページ遷移時の配列保持や、Pinia を使ったグローバルステート管理の具体例を取り上げる予定です。

参考資料