Vue 3 でセレクトボックスからページ遷移する — 初期値で詰む4パターンを実測した
先に結論
セレクトボックスを選んだらそのページへ飛ばす、という実装です。 コード自体は10行で終わりますが、詰まるのは本体ではなく「初期値をどう置くか」でした。
v-model の初期値と <option> の置き方の組み合わせで4パターンあり、
Vue 3.5.13 と Chrome 152 で全部動かして確かめました。
| 書き方 | selectedIndex | 起きること | |
|---|---|---|---|
| A | プレースホルダなし・初期値 '' | -1 | 選択肢が何も表示されない(詳しく) |
| B | <option disabled value=""> | 0 | 正常。公式が推奨している形(詳しく) |
| C | <option value="">(disabled なし) | 0 | プレースホルダを選び直すと現在ページがリロードされる(詳しく) |
| D | 初期値を最初の項目に一致させる | 0 | 最初の項目を選んでも遷移しない(詳しく) |
結論は3行です。
- Bを使う。
<option disabled value="">を先頭に置く。公式ドキュメントがそう書いています。 - SPAなら
location.hrefではなくrouter.push()。ページ全体が再読み込みされて、SPAである意味がなくなります(詳しく)。 - そもそも select で遷移させるのは、WCAGのレベルAに引っかかる形です。使うなら対処が要ります(詳しく)。
ネット上に残っているこの実装のサンプルは、ほとんどが 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 の両方を書いています。
片方で済ませようとすると、どちらも微妙に困ります。
| 書き方 | 選択値の管理 | 遷移のきっかけ | 評価 |
|---|---|---|---|
v-model + @change | v-model | @change | 推奨。値は状態として持ちつつ、遷移は明示的 |
@change だけ(e.target.value を読む) | 持たない | @change | 動くが、選択値を他で使えない。初期値も制御できない |
v-model + watch | v-model | watch | 動くが、プログラムから値を変えたときも遷移してしまう |
v-model だけ | v-model | なし | 遷移しない |
3行目の watch は要注意です。watch は「誰が変えたか」を区別しません。
URLの状態を復元するために selected.value を代入した瞬間にも遷移が走ります。
ユーザーの操作を拾いたいなら @change です。
なお @change が発火するのは、MDNの表現では
「ユーザーが明示的に変更を確定したとき」です。
値が変わらなければ発火しません。これがパターンDの原因になります。
初期値で詰む4パターン(実測)
同じ選択肢を持つ <select> を4つ並べて、初期値と <option> の置き方だけを変え、
マウント直後のDOMの状態を読み出しました。
| 初期値 | 先頭のoption | selectedIndex | value | 画面の表示 | |
|---|---|---|---|---|---|
| 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 とも一致していないだけです。
| 初期値 | 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 を確認しました。
先頭optionの disabled | ユーザーが選び直せるか | |
|---|---|---|
| B(公式推奨) | true | 選べない |
| C | false | 選べる |
選び直すと selected が '' になり、@change が走ります。
そこで location.href = '' が実行されると、どうなるか。
空文字は現在のURLに解決されます。手元で確認しました。
new URL('', location.href).href は、現在のページのURLそのものを返します。
つまり何も起きていないように見えて、ページが再読み込みされます。
フォームに入力途中の値があれば消えます。原因が分かりにくい部類のバグです。
前のコードの if (!selected.value) return; はこれを防ぐためのものですが、
そもそも disabled を付けておけば選ばせずに済みます。
D:初期値を一致させる — 最初の項目で遷移しない
プレースホルダを置きたくないので、初期値を最初の項目('/a/')に合わせた場合です。
selectedIndex は 0、表示も「ページA」で、一見きれいに見えます。
しかし、ユーザーが「ページA」を選んでも遷移しません。
もう選ばれているので、値が変わらず、@change が発火しないからです。
| ユーザーの操作 | 値の変化 | @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="" がセットになっているのがポイントです。
| 属性 | 役割 | 無いと |
|---|---|---|
value="" | 初期値 '' と一致させる | A:unselected になり、何も表示されない |
disabled | プレースホルダを選ばせない | C:選び直すと現在ページがリロードされる |
| 先頭に置くこと | どの項目も「未選択からの変化」にする | D:最初の項目で @change が発火しない |
iOSで最初の項目が選べなくなる
公式が disabled 付きのプレースホルダを勧めている理由は、見た目の問題ではありません。
ドキュメントにはこう書かれています。
「v-model 式の初期値がどのオプションにもマッチしない場合、<select> 要素は "unselected" 状態でレンダリングされます。
iOS では、このような場合に change イベントが発火しないため、ユーザーは最初のアイテムを選択できないことになります。
したがって、上記の例のように、空の値を持つ disabled オプションを提供することが推奨されます。」
— フォーム入力バインディング(Vue.js 公式)
つまりパターンAは、デスクトップでは「表示が空なだけ」で済みますが、 iOSでは最初の項目が選べなくなります。 リンクメニューとして作っているなら、選択肢のひとつが iPhone から到達できないということです。
| 環境 | 症状 |
|---|---|
| デスクトップ | 表示が空。選べば遷移する |
| 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 };
},
};
| やりたいこと | 書き方 | 履歴 | 再読み込み |
|---|---|---|---|
| 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です。
ただし、条文には除外規定があります。 「利用者がそのコンポーネントを使用する前にその挙動を知らされている場合」。 ここが対処の手がかりになります。
使うなら、こうする
| 対処 | 何が解決するか | 手間 |
|---|---|---|
| 「移動」ボタンを置き、押したときだけ遷移する | 自動的な変化でなくなる。除外規定も不要 | 小 |
| ラベルに「選ぶとページが移動します」と明示する | 除外規定(事前に知らされている)に当てはめる | 小 |
<a> のリストに置き換える | 基準に当たらなくなる。新しいタブでも開ける | 中 |
<details> + リンクのリストにする | 同上。畳んでおける | 中 |
一番安いのは「移動」ボタンです。
@change での遷移をやめて、ボタンの @click に移すだけで、
「自動的に引き起こさない」を満たせます。
ただし、本当にリンクの一覧なら、<a> で並べたほうが素直です。
セレクトボックスにした時点で失われるものがあります。
リンク(<a>)でできること | select では |
|---|---|
| 新しいタブで開く(中クリック・修飾キー) | できない |
| リンク先URLをステータスバーで確認する | できない |
| URLをコピーする(右クリック) | できない |
| 検索エンジンにリンクとして辿られる | 辿られない |
| JavaScriptが動かない状況でも遷移する | 遷移しない |
最後の行は見落とされがちです。
<a> ならJavaScriptが落ちても動きますが、
select のリンクメニューはスクリプトが動かなければただの飾りになります。
選択肢が多くて場所を取る、という理由だけで select にしているなら、折りたたみを検討してください。 選択肢が5つ程度なら、リンクを並べたほうが速くて壊れません。
Vue 2 のコードを移植する
検索して出てくるサンプルはほぼ Vue 2 です。 Vue 2 は 2023年12月31日にサポート終了(最終版 2.7.16)しており、 公式は「新機能・更新・修正は一切提供されない」としています。
| Vue 2 | Vue 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.selected | selected.value(ref なので .value が要る) |
v-for に :key なし | :key を付ける |
一番間違えるのは最後から2行目です。
setup() の中では this は使えず、ref の中身は .value で読みます。
テンプレート側では .value を書きません。ここだけ非対称なので、移植のときに混ざります。
AIに書かせるときの注意
このテーマはAIに頼みやすい部類ですが、学習データの大半が Vue 2 時代のサンプルです。
何も言わないと new Vue({ el: ... }) が返ってきます。
| 頼むこと | 向くか | 理由 |
|---|---|---|
| 「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)に当たります(条文)。 「移動」ボタンを置いて押したときだけ遷移させるか、挙動を事前にラベルで知らせてください。 リンクの一覧にできるなら、そのほうが確実です。
まとめ
- 詰まるのは実装本体ではなく初期値の置き方。4パターンあり、3つが壊れる。
- 先頭に
<option disabled value="">を置き、初期値を''にする。これで3つの問題が同時に消える。 - 初期値がどの
valueとも一致しないとselectedIndexが -1 になり、iOSでは最初の項目が選べなくなる(公式の記載)。 disabledを付け忘れると、プレースホルダを選び直せる。location.href = ''は現在ページに解決されるのでリロードになる。- 初期値を最初の項目に合わせると、その項目だけ遷移しない。値が変わらず
@changeが発火しない。 - SPAでは
location.hrefではなくrouter.push()。戻らせたくないならrouter.replace()。 watchで遷移させない。プログラムからの代入でも発火する。- 検索で出てくるサンプルはほぼ Vue 2。2023年12月31日にサポート終了している。
new Vue(が見えたら作り直す。 - この形は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 を確認してください。