Vue 3 でセレクトボックスからページ遷移する — 初期値で詰む4パターンを実測した

先に結論

セレクトボックスを選んだらそのページへ飛ばす、という実装です。 コード自体は10行で終わりますが、詰まるのは本体ではなく「初期値をどう置くか」でした。

v-model の初期値と <option> の置き方の組み合わせで4パターンあり、 Vue 3.5.13 と Chrome 152 で全部動かして確かめました。

表1:初期値の置き方で何が起きるか(実測)
書き方selectedIndex起きること
Aプレースホルダなし・初期値 ''-1選択肢が何も表示されない(詳しく)
B<option disabled value="">0正常。公式が推奨している形(詳しく)
C<option value="">(disabled なし)0プレースホルダを選び直すと現在ページがリロードされる(詳しく)
D初期値を最初の項目に一致させる0最初の項目を選んでも遷移しない(詳しく)

結論は3行です。

ネット上に残っているこの実装のサンプルは、ほとんどが new Vue({ el: '#app' }) で書かれた Vue 2 のコードです。 Vue 2 は2023年12月31日にサポートが終了しました(最終版 2.7.16)。 公式は終了後について「新機能・更新・修正は一切提供されない」と書いています。 そのままコピーしても Vue 3 では動きません(移植の対応表)。

Vue 3 での最小構成

動くコード

先に完成形を置きます。これがパターンBです。

<div id="app">
  <select v-model="selected" @change="jump">
    <option disabled value="">ページを選んでください</option>
    <option v-for="o in options" :key="o.value" :value="o.value">
      {{ o.text }}
    </option>
  </select>
</div>

<script>
const { createApp, ref } = Vue;

createApp({
  setup() {
    const selected = ref('');          // ← 空文字。先頭のoptionと一致させる
    const options = [
      { text: '料金ページ', value: '/price/' },
      { text: 'よくある質問', value: '/faq/' },
    ];
    function jump() {
      if (!selected.value) return;     // 念のため空は弾く
      location.href = selected.value;
    }
    return { selected, options, jump };
  },
}).mount('#app');
</script>

ref('') の空文字が、先頭の <option disabled value=""> と一致しています。 この一致が崩れるとパターンAになります。

v-model と @change の役割分担

この実装では v-model と @change の両方を書いています。 片方で済ませようとすると、どちらも微妙に困ります。

表2:何をどちらに任せるか
書き方選択値の管理遷移のきっかけ評価
v-model + @changev-model@change推奨。値は状態として持ちつつ、遷移は明示的
@change だけ(e.target.value を読む)持たない@change動くが、選択値を他で使えない。初期値も制御できない
v-model + watchv-modelwatch動くが、プログラムから値を変えたときも遷移してしまう
v-model だけv-modelなし遷移しない

3行目の watch は要注意です。watch は「誰が変えたか」を区別しません。 URLの状態を復元するために selected.value を代入した瞬間にも遷移が走ります。 ユーザーの操作を拾いたいなら @change です。

なお @change が発火するのは、MDNの表現では 「ユーザーが明示的に変更を確定したとき」です。 値が変わらなければ発火しません。これがパターンDの原因になります。

初期値で詰む4パターン(実測)

同じ選択肢を持つ <select> を4つ並べて、初期値と <option> の置き方だけを変え、 マウント直後のDOMの状態を読み出しました。

表3:4パターンの実測値(Vue 3.5.13 / Chrome 152)
初期値先頭のoptionselectedIndexvalue画面の表示
A''なし-1''空っぽ
B''disabled value=""0''「選択してください」
C''value=""0''「選択してください」
D'/a/'なし0'/a/'「ページA」

BとCは実測値が同じです。違いが出るのは、ユーザーが操作したあとです。

A:プレースホルダなし — 何も表示されない

selected の初期値を '' にしたまま、空の <option> を置かなかった場合です。 selectedIndex が -1 になりました。

これは Vue の公式ドキュメントが名前を付けて警告している状態です。 「v-model 式の初期値がどのオプションにもマッチしない場合、 <select> 要素は "unselected" 状態でレンダリングされます」とあります。

画面上は何も選ばれていない、中身が空のセレクトボックスになります。 「選択肢が表示されない」「最初の項目が出ない」という症状でつまずくのはこれです。 Vue のバグでも書き方のミスでもなく、初期値がどの value とも一致していないだけです。

表4:パターンAになる組み合わせ
初期値optionのvalue結果
'''/a/', '/b/'unselected
null'/a/', '/b/'unselected
undefined(ref() を引数なしで呼ぶ)'/a/', '/b/'unselected
0'0'(文字列)unselected。型が違う
'/a/''/a/', '/b/'選択される

4行目は見落としがちです。:value="o.value" と書けば型はそのまま保たれますが、 <option value="0"> とHTMLに直接書いた場合は文字列になります。 数値の初期値とは一致しません。

C:disabled を付け忘れる — 現在ページに戻る

プレースホルダは置いたけれど disabled を付けなかった場合です。 マウント直後の状態はBと変わりません。問題は、ユーザーがプレースホルダを選び直せてしまうことです。

実測で先頭optionの disabled を確認しました。

表5:先頭optionの状態(実測)
先頭optionの disabledユーザーが選び直せるか
B(公式推奨)true選べない
Cfalse選べる

選び直すと selected が '' になり、@change が走ります。 そこで location.href = '' が実行されると、どうなるか。

空文字は現在のURLに解決されます。手元で確認しました。 new URL('', location.href).href は、現在のページのURLそのものを返します。 つまり何も起きていないように見えて、ページが再読み込みされます。

フォームに入力途中の値があれば消えます。原因が分かりにくい部類のバグです。 前のコードの if (!selected.value) return; はこれを防ぐためのものですが、 そもそも disabled を付けておけば選ばせずに済みます。

D:初期値を一致させる — 最初の項目で遷移しない

プレースホルダを置きたくないので、初期値を最初の項目('/a/')に合わせた場合です。 selectedIndex は 0、表示も「ページA」で、一見きれいに見えます。

しかし、ユーザーが「ページA」を選んでも遷移しません。 もう選ばれているので、値が変わらず、@change が発火しないからです。

表6:パターンDでの操作と結果
ユーザーの操作値の変化@change遷移
「ページB」を選ぶ/a/ → /b/発火するする
「ページA」を選ぶ(初期表示のまま)変化なし発火しないしない
ページBへ行ったあと、戻るで帰ってくる初期値に戻る発火しないしない

「1つだけ選べない項目がある」という報告の正体がこれです。 最初の項目だけが死んでいるので、テストで気づきにくい。

3行目も地味に効きます。ブラウザの戻るで帰ってくると、セレクトボックスは初期値の表示に戻ります。 「さっき選んだページ」が表示されていないので、ユーザーは混乱します。

B:公式推奨のかたち

Vue の公式ドキュメントに載っているのが、この形です。

<select v-model="selected">
  <option disabled value="">Please select one</option>
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>

disabled と value="" がセットになっているのがポイントです。

表7:Bが3つの問題を同時に消している
属性役割無いと
value=""初期値 '' と一致させるA:unselected になり、何も表示されない
disabledプレースホルダを選ばせないC:選び直すと現在ページがリロードされる
先頭に置くことどの項目も「未選択からの変化」にするD:最初の項目で @change が発火しない

iOSで最初の項目が選べなくなる

公式が disabled 付きのプレースホルダを勧めている理由は、見た目の問題ではありません。 ドキュメントにはこう書かれています。

「v-model 式の初期値がどのオプションにもマッチしない場合、<select> 要素は "unselected" 状態でレンダリングされます。 iOS では、このような場合に change イベントが発火しないため、ユーザーは最初のアイテムを選択できないことになります。 したがって、上記の例のように、空の値を持つ disabled オプションを提供することが推奨されます。」
— フォーム入力バインディング(Vue.js 公式)

つまりパターンAは、デスクトップでは「表示が空なだけ」で済みますが、 iOSでは最初の項目が選べなくなります。 リンクメニューとして作っているなら、選択肢のひとつが iPhone から到達できないということです。

表8:パターンAの影響範囲
環境症状
デスクトップ表示が空。選べば遷移する
iOS最初のアイテムを選択できない(公式の記載)

iOSでの挙動は公式ドキュメントの記載であり、手元で実機確認はしていません。 デスクトップChromeでの selectedIndex: -1 は実測しました。

SPAなら location.href を使わない

ここまで location.href で書いてきましたが、これはSPAでは間違いです。 ページ全体が再読み込みされ、アプリの状態が全部消えます。

Vue Router を使っているなら、公式の書き方はこうです。

import { useRouter } from 'vue-router';

export default {
  setup() {
    const router = useRouter();        // 公式:コンポーネント内では useRouter() で取得する
    const selected = ref('');

    function jump() {
      if (!selected.value) return;
      router.push(selected.value);     // 例: router.push('/users/eduardo')
    }
    return { selected, jump };
  },
};
表9:遷移方法の使い分け
やりたいこと書き方履歴再読み込み
SPA内の画面を切り替えるrouter.push(path)積まれるなし
SPA内で、戻るで帰らせたくないrouter.replace(path)積まれない(現在の履歴を置き換える)なし
別サイト・別ドメインへ行くlocation.href = url積まれるあり
SPAではない普通のページ間location.href = url積まれるあり
そもそも遷移させたくない<router-link> / <a> で並べる——

router.push は文字列のパスのほか、 router.push({ name: 'user', params: { username: 'eduardo' } }) のように 名前付きルートやクエリでも呼べます。選択肢の value にパスを埋め込むより、 ルート名を持たせたほうが壊れにくいです。

router.push は内部で History API を使います。 この遷移をアクセス解析で拾えるかどうかは別の問題で、設定を間違えると画面を切り替えてもPVが増えません。 そちらは GA4でSPAのページビューが計測されない5つの原因 に書きました。

そもそも select で遷移していいのか

WCAGが何と言っているか

実装の話とは別に、この「選んだら飛ぶ」という形そのものに問題があります。 WCAG 2.2 の達成基準 3.2.2「入力時」は、レベルAでこう定めています。

「いかなるユーザインタフェースコンポーネントの設定変更も、コンテキストの変化を自動的には引き起こさない。 ただし、利用者がそのコンポーネントを使用する前にその挙動を知らされている場合を除く。」
— WCAG 2.2 達成基準 3.2.2 入力時(レベル A・日本語訳)

セレクトボックスの選択は「ユーザインタフェースコンポーネントの設定変更」、 ページ遷移は「コンテキストの変化」です。 選んだ瞬間に飛ぶ実装は、この基準にそのまま当たります。最も低いレベルAです。

ただし、条文には除外規定があります。 「利用者がそのコンポーネントを使用する前にその挙動を知らされている場合」。 ここが対処の手がかりになります。

使うなら、こうする

表10:select で遷移させる場合の対処
対処何が解決するか手間
「移動」ボタンを置き、押したときだけ遷移する自動的な変化でなくなる。除外規定も不要小
ラベルに「選ぶとページが移動します」と明示する除外規定(事前に知らされている)に当てはめる小
<a> のリストに置き換える基準に当たらなくなる。新しいタブでも開ける中
<details> + リンクのリストにする同上。畳んでおける中

一番安いのは「移動」ボタンです。 @change での遷移をやめて、ボタンの @click に移すだけで、 「自動的に引き起こさない」を満たせます。

ただし、本当にリンクの一覧なら、<a> で並べたほうが素直です。 セレクトボックスにした時点で失われるものがあります。

表11:select にすると失われるもの
リンク(<a>)でできることselect では
新しいタブで開く(中クリック・修飾キー)できない
リンク先URLをステータスバーで確認するできない
URLをコピーする(右クリック)できない
検索エンジンにリンクとして辿られる辿られない
JavaScriptが動かない状況でも遷移する遷移しない

最後の行は見落とされがちです。 <a> ならJavaScriptが落ちても動きますが、 select のリンクメニューはスクリプトが動かなければただの飾りになります。

選択肢が多くて場所を取る、という理由だけで select にしているなら、折りたたみを検討してください。 選択肢が5つ程度なら、リンクを並べたほうが速くて壊れません。

Vue 2 のコードを移植する

検索して出てくるサンプルはほぼ Vue 2 です。 Vue 2 は 2023年12月31日にサポート終了(最終版 2.7.16)しており、 公式は「新機能・更新・修正は一切提供されない」としています。

表12:Vue 2 → Vue 3 の対応(この実装で使う範囲)
Vue 2Vue 3
new Vue({ el: '#app', ... })createApp({ ... }).mount('#app')
data: { selected: '' }setup() { const selected = ref(''); ... }
methods: { jump() {} }setup() の中で普通の関数として定義し、return する
v-on:change="jump"@change="jump"(Vue 2 でも同じ。省略記法)
v-bind:value="o.value":value="o.value"(同上)
this.selectedselected.value(ref なので .value が要る)
v-for に :key なし:key を付ける

一番間違えるのは最後から2行目です。 setup() の中では this は使えず、ref の中身は .value で読みます。 テンプレート側では .value を書きません。ここだけ非対称なので、移植のときに混ざります。

AIに書かせるときの注意

このテーマはAIに頼みやすい部類ですが、学習データの大半が Vue 2 時代のサンプルです。 何も言わないと new Vue({ el: ... }) が返ってきます。

表13:AIへの頼み方
頼むこと向くか理由
「Vue 3 の Composition API で」とバージョンを明示して書かせる向く明示すれば createApp / ref で出る
Vue 2 のコードを貼って「Vue 3 に移植して」向く表12の範囲の機械的な変換
「選択肢が表示されない。原因は?」(コードを貼る)向く初期値とvalueの不一致は読めば分かる
「Vue で select から遷移する方法を教えて」向かないVue 2 のコードが返る。初期値の罠には触れない
「アクセシビリティ的に問題ない?」向かない一般論が返る。WCAGの条文を自分で読む
生成されたコードの動作確認向かないselectedIndex は自分でブラウザで見る

判定は簡単です。出てきたコードに new Vue( があれば Vue 2なので、作り直させてください。 createApp( なら Vue 3 です。

よくある質問

セレクトボックスに何も表示されません

v-model の初期値が、どの <option> の value とも一致していません(パターンA)。 公式が "unselected" と呼んでいる状態です。 先頭に <option disabled value=""> を置き、初期値を '' にしてください。

最初の項目だけ遷移しません

初期値がその項目と一致していて、すでに選択済みだからです(パターンD)。 値が変わらないので @change が発火しません。 プレースホルダを先頭に置けば、どの項目も「変化」になります。

選択し直すとページが再読み込みされます

プレースホルダに disabled が付いておらず、選び直せる状態です(パターンC)。 location.href = '' は現在のページに解決されるので、リロードになります。 disabled を付けてください。

v-model と @change、どちらか片方で書けませんか

書けますが、それぞれ欠けるものがあります(表2)。 @change だけだと選択値を状態として持てず、初期値も制御できません。 watch での代用は避けてください。プログラムから値を代入したときにも遷移します。

SPAで使うと画面が真っ白になります

location.href でページ全体を再読み込みしている可能性が高いです。 Vue Router を使っているなら useRouter() で取得して router.push() を呼びます(詳しく)。

戻るボタンで帰ると、選択が元に戻ります

初期値が再適用されるためです。 気になるなら、現在のパスを初期値に入れておくか、そもそもリンクの一覧にするのが確実です(詳しく)。

数値のvalueが一致しません

<option value="0"> とHTMLに直接書くと文字列の "0" になり、数値の 0 とは一致しません(表4)。 :value="o.value" とバインドすれば型が保たれます。

アクセシビリティ的に問題がありますか

WCAG 2.2 の達成基準 3.2.2「入力時」(レベルA)に当たります(条文)。 「移動」ボタンを置いて押したときだけ遷移させるか、挙動を事前にラベルで知らせてください。 リンクの一覧にできるなら、そのほうが確実です。

まとめ

同じ「エラーや症状から原因を切り分ける」型の話は Unexpected token '<' ... is not valid JSON の原因と直し方、 SPAの遷移を計測する話は GA4でSPAのページビューが計測されない5つの原因、 ブラウザ側に状態を持たせる話は localStorage の使い方と、2026年に気をつけること に書きました。

selectedIndex と value の実測値は、 2026年10月25日に Vue 3.5.13 と Chrome 152 で実際に動かして取得したものです。 iOSでの挙動は公式ドキュメントの記載で、実機では確認していません。 仕様は変わります。実装したら、手元で selectedIndex を確認してください。