- Vue.js
VueのrefでDOM取得|useTemplateRefと子の参照
Vueのテンプレート参照とは、テンプレート内の要素や子コンポーネントに ref 属性で名前を付け、スクリプト側からその実体(DOM要素やコンポーネントのインスタンス)を受け取る仕組みです。 Vue 3.5 以降は useTemplateRef() で受け取り、3.4 以前は同じ名前の ref(null) で、Options API は this.$refs で受け取ります。
この記事では、3つの書き方を対応表で並べたうえで、input 要素へのフォーカス、v-for での配列参照、子コンポーネントのメソッド呼び出しを全文コードで示します。さらに「参照が null や undefined になる」4つの原因を再現コードと直し方つきで一覧にし、TypeScript の型付けもコピーして使える形で載せました。掲載した出力はすべて Vue 3.5.43 を手元で動かして得たものです。
Vueテンプレート参照の早見表
まず「やりたいこと」から書き方を引ける表です。Vue 3.5 以降の <script setup> を基準にしています。
| やりたいこと | テンプレート側 | スクリプト側 | 受け取れるもの |
|---|---|---|---|
| DOM要素を1つ取得する | ref="name-input" |
useTemplateRef('name-input') |
要素(マウント前は null) |
| input にフォーカスする | ref="name-input" |
onMounted の中で el.value?.focus() |
― |
| v-for の要素をまとめて取得する | v-for と同じ要素に ref="items" |
useTemplateRef('items') |
要素の配列 |
| 行ごとにIDで引けるようにする | :ref="(el) => setRow(id, el)" |
Map に自分で保存 | 関数の引数に要素(外れる時は null) |
| 子コンポーネントのメソッドを呼ぶ | 子のタグに ref="counter" |
counter.value?.increment() |
子が defineExpose で公開したものだけ |
| 子のルート要素(DOM)を触る | 子のタグに ref="counter" |
counter.value?.$el |
子のルートDOM要素 |
| v-if で出し入れする要素を触る | v-if と ref を同じ要素に |
表示を切り替えたあと await nextTick() |
表示中は要素、非表示中は null |
| 参照の変化を見張る | ― | watch(el, (v) => ...) |
付いた時に要素、外れた時に null |
バージョンごとの受け取り方は次の3通りです。
| 環境 | スクリプト側の書き方 | 名前を合わせる相手 |
|---|---|---|
| Vue 3.5 以降(Composition API) | const el = useTemplateRef('name-input') |
引数の文字列と ref 属性の値 |
| Vue 3.4 以前(Composition API) | const nameInput = ref(null) |
変数名と ref 属性の値 |
| Options API(Vue 2 / Vue 3 共通) | this.$refs.nameInput |
$refs のプロパティ名と ref 属性の値 |
確認した環境と動かし方
この記事の出力は、次の環境で採取しました。
| 項目 | 内容 |
|---|---|
| Vue | 3.5.43(npm の latest。2026-10-03 に確認) |
| SFC のコンパイル | @vue/compiler-sfc 3.5.43 |
| DOM | Node.js 22.22.2 + jsdom 30.1.1 |
| 型チェック | vue-tsc 3.3.12 + TypeScript 6.0.3 |
単一ファイルコンポーネント(.vue)を @vue/compiler-sfc でコンパイルし、jsdom 上で createApp().mount() して console.log の内容を取っています。フォーカスは document.activeElement で確かめました。実ブラウザでの表示は未実行です(jsdom はレイアウトを持たないため、スクロール位置などの見た目は確認していません)。
型チェックについて補足します。npm の typescript は latest が 7.0.2 でしたが、vue-tsc 3.3.12 は TypeScript 7.0.2 と組み合わせると起動時に ERR_PACKAGE_PATH_NOT_EXPORTED で止まりました。そのため型エラーの文面は TypeScript 6.0.3 で採取しています。
また、vue 3.4.38 も入れて、useTemplateRef が export されていないこと(typeof が undefined)を確かめました。3.4 以前の書き方(ref(null))のコードは 3.5.43 上で動かしており、3.4 系そのものでの画面動作は未実行です。「3.5 以降が必要」という点は Vue 公式ドキュメント(テンプレート参照のページ)でも確認しました。
useTemplateRef・ref(null)・this.$refsの対応表
3つの書き方は「どこで名前を合わせるか」が違うだけで、取得できるものは同じです。
| 比べる点 | Vue 3.5 以降 | Vue 3.4 以前 | Options API |
|---|---|---|---|
| 受け取り方 | useTemplateRef('キー') |
ref(null) |
this.$refs.キー |
| テンプレート側 | ref="キー" |
ref="変数名" |
ref="キー" |
| 名前の規則 | 文字列が一致すればよい(ハイフン入りも可) | 変数名と一致させる | プロパティ名と一致させる |
| 使えるタイミング | onMounted 以降 | onMounted 以降 | mounted 以降 |
| マウント前の値 | null | null | undefined($refs にキーがない) |
| v-for に付けた時 | 配列 | 配列(ref([]) で用意) |
配列 |
| 子コンポーネントに付けた時 | 公開されたものだけ見える | 公開されたものだけ見える | expose 指定がなければ全部見える |
| 型の付け方 | ジェネリクスで要素の型を渡す。vue-tsc の自動推論も効く | ref のジェネリクスに「要素の型または null」を渡す |
as HTMLInputElement などで指定 |
| 普通の ref との見分け | 関数名で参照だと分かる | 値を入れる ref と見た目が同じ | $refs で分かる |
Vue 3.5 以降で新しく書くなら useTemplateRef が読みやすくなります。 理由は2つあります。1つは、値を持つ ref と、要素を受け取る参照がコード上で区別できること。もう1つは、変数名と ref 属性の値を別々に決められることです。3.4 以前の書き方も 3.5 でそのまま動くので、既存コードを急いで書き換える必要はありません。
なお、3.5.43 では「変数名とキーが同じ」書き方(const input = useTemplateRef('input'))も問題なく動き、要素を取得できました。
input要素にフォーカスする全文コード
もっともよく使う例です。画面を開いた時に入力欄へカーソルを置きます。
Vue 3.5以降:useTemplateRef
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
const inputEl = useTemplateRef<HTMLInputElement>('name-input')
console.log('setup中:', inputEl.value)
onMounted(() => {
console.log('onMounted:', inputEl.value?.tagName)
inputEl.value?.focus()
})
</script>
<template>
<input ref="name-input" placeholder="名前" />
</template>
実行結果(Vue 3.5.43。最後の行はマウント後の document.activeElement):
setup中: null
onMounted: INPUT
activeElement: INPUT 名前
setup の直下ではまだ null で、onMounted の中で要素になっていることが分かります。テンプレートが描画されて初めて要素が存在するためです。
Vue 3.4以前:ref(null)
<script setup lang="ts">
import { onMounted, ref } from 'vue'
// 変数名を ref 属性の値(nameInput)と同じにする
const nameInput = ref<HTMLInputElement | null>(null)
onMounted(() => {
nameInput.value?.focus()
})
</script>
<template>
<input ref="nameInput" placeholder="名前" />
</template>
実行結果(Vue 3.5.43 で実行):
activeElement: INPUT 名前
この書き方では、変数名とテンプレートの ref 属性の値を同じにすることが条件です。
Options API:this.$refs
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
mounted() {
const el = this.$refs.nameInput as HTMLInputElement
el.focus()
},
})
</script>
<template>
<input ref="nameInput" placeholder="名前" />
</template>
実行結果(Vue 3.5.43):
activeElement: INPUT 名前
Options API では mounted フックの中で this.$refs から取り出します。created の段階ではまだキーが存在せず、undefined が返りました(後半の原因一覧で再現します)。
v-forで要素を配列として受け取る
v-for と同じ要素に ref を付けると、参照の値は要素の配列になります。
<script setup lang="ts">
import { nextTick, onMounted, ref, useTemplateRef } from 'vue'
const tasks = ref(['資料作成', '備品発注', '記事レビュー'])
const itemEls = useTemplateRef<HTMLLIElement[]>('items')
onMounted(async () => {
console.log('件数:', itemEls.value?.length)
console.log('中身:', itemEls.value?.map((el) => el.textContent))
tasks.value.push('在庫確認')
console.log('push直後:', itemEls.value?.length)
await nextTick()
console.log('nextTick後:', itemEls.value?.length)
})
</script>
<template>
<ul>
<li v-for="task in tasks" :key="task" ref="items">{{ task }}</li>
</ul>
</template>
実行結果(Vue 3.5.43):
件数: 3
中身: [ '資料作成', '備品発注', '記事レビュー' ]
push直後: 3
nextTick後: 4
データを追加した直後はまだ3件で、nextTick を待つと4件になります。 Vue は DOM の更新をまとめて行うので、追加した行の要素を触りたい時は await nextTick() のあとに書きます。
3.4 以前の書き方では const items = ref<HTMLLIElement[]>([]) と配列で用意し、テンプレートは ref="items" とします。Options API では this.$refs.rows が配列になります(後述のコードで Array.isArray が true になることを確認しました)。
配列の並びはデータの並びと同じとは限らない
並べ替えや先頭への追加をした時、参照の配列がどうなるかを確かめました。
<script setup lang="ts">
import { nextTick, onMounted, ref, useTemplateRef } from 'vue'
const nums = ref([1, 2, 3])
const els = useTemplateRef<HTMLLIElement[]>('els')
onMounted(async () => {
console.log('最初:', els.value?.map((e) => e.textContent))
nums.value = [3, 1, 2]
await nextTick()
console.log('並べ替え後 データ:', nums.value)
console.log('並べ替え後 参照:', els.value?.map((e) => e.textContent))
nums.value.unshift(0)
await nextTick()
console.log('先頭追加後 データ:', nums.value)
console.log('先頭追加後 参照:', els.value?.map((e) => e.textContent))
})
</script>
<template>
<ul>
<li v-for="n in nums" :key="n" ref="els">{{ n }}</li>
</ul>
</template>
実行結果(Vue 3.5.43):
最初: [ '1', '2', '3' ]
並べ替え後 データ: [ 3, 1, 2 ]
並べ替え後 参照: [ '1', '2', '3' ]
先頭追加後 データ: [ 0, 3, 1, 2 ]
先頭追加後 参照: [ '1', '2', '3', '0' ]
データを [3, 1, 2] に並べ替えても参照の配列は [1, 2, 3] のまま、先頭に追加した 0 は参照の配列では末尾に入りました。 つまり「参照の配列の i 番目」と「データの i 番目」は一致しないことがあります。全要素に同じ処理をする用途(件数を数える、まとめて高さを測るなど)には配列参照が手軽で、特定の行を狙う用途には次の関数 ref が向いています。
行ごとにIDで引ける関数ref
ref 属性に関数を渡すと、要素が付いた時と外れた時にその関数が呼ばれます。IDをキーにした Map へ自分で保存すれば、並び順に左右されません。
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import type { ComponentPublicInstance } from 'vue'
const ids = ref([101, 102, 103])
const rowEls = new Map<number, HTMLElement>()
function setRow(id: number, el: Element | ComponentPublicInstance | null) {
if (el) rowEls.set(id, el as HTMLElement)
else rowEls.delete(id)
}
onMounted(async () => {
console.log('キー:', [...rowEls.keys()])
console.log('102:', rowEls.get(102)?.textContent)
ids.value = [101, 103]
await nextTick()
console.log('削除後のキー:', [...rowEls.keys()])
})
</script>
<template>
<ul>
<li v-for="id in ids" :key="id" :ref="(el) => setRow(id, el)">行 {{ id }}</li>
</ul>
</template>
実行結果(Vue 3.5.43):
キー: [ 101, 102, 103 ]
102: 行 102
削除後のキー: [ 101, 103 ]
要素が外れる時は引数に null が渡されるので、その時に Map から消しています。関数 ref は :ref(コロン付き)で書く点がポイントです。この書き方は 3.4 以前でも同じです。
子コンポーネントのメソッドを親から呼ぶ
子コンポーネントのタグに ref を付けると、参照の値は子のインスタンスになります。<script setup> で書いた子は初期状態では中身を外に見せないため、親に使わせたいものを defineExpose で並べます。
子(Counter.vue):
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const secret = ref('公開しない値')
function increment() {
count.value++
}
function reset() {
count.value = 0
}
// 親に見せるものだけを並べる
defineExpose({ count, increment, reset })
</script>
<template>
<p>count: {{ count }}</p>
</template>
親(Parent.vue):
<script setup lang="ts">
import { nextTick, onMounted, useTemplateRef } from 'vue'
import Counter from './Counter.vue'
const counter = useTemplateRef<InstanceType<typeof Counter>>('counter')
onMounted(async () => {
counter.value?.increment()
counter.value?.increment()
console.log('count:', counter.value?.count)
console.log('secret:', (counter.value as any)?.secret)
await nextTick()
console.log('DOM:', counter.value?.$el.textContent)
counter.value?.reset()
await nextTick()
console.log('reset後:', counter.value?.$el.textContent)
})
</script>
<template>
<Counter ref="counter" />
</template>
実行結果(Vue 3.5.43):
count: 2
secret: undefined
DOM: count: 2
reset後: count: 0
読み取れることは3つあります。
- defineExpose に入れた increment と reset は親から呼べる
- 入れていない secret は undefined になる(子の内部は隠れたまま)
- 公開した count は ref ですが、親からは
.valueなしで数値として読める(counter.value?.countが 2)
子のルートDOM要素は $el で取れます。defineExpose の有無に関係なく使えます。
Options APIの子はexposeで公開範囲を絞る
Options API で書いた子は、何も指定しなければ data や methods がすべて親から見えます。expose オプションを書くと、見せる範囲を絞れます。
子(OptChild.vue):
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
expose: ['reset'],
data() {
return { count: 5 }
},
methods: {
reset() {
this.count = 0
},
hidden() {},
},
})
</script>
<template>
<p>count: {{ count }}</p>
</template>
親(OptParent.vue):
<script lang="ts">
import { defineComponent } from 'vue'
import OptChild from './OptChild.vue'
export default defineComponent({
components: { OptChild },
data() {
return { tasks: ['資料作成', '備品発注'] }
},
created() {
console.log('created:', this.$refs.child)
},
async mounted() {
const child = this.$refs.child as InstanceType<typeof OptChild>
console.log('reset:', typeof child.reset, '/ hidden:', typeof (child as any).hidden, '/ count:', (child as any).count)
child.reset()
await this.$nextTick()
console.log('DOM:', child.$el.textContent)
const rows = this.$refs.rows as HTMLLIElement[]
console.log('rows:', Array.isArray(rows), rows.map((el) => el.textContent))
},
})
</script>
<template>
<OptChild ref="child" />
<ul>
<li v-for="task in tasks" :key="task" ref="rows">{{ task }}</li>
</ul>
</template>
実行結果(Vue 3.5.43):
created: undefined
reset: function / hidden: undefined / count: undefined
DOM: count: 0
rows: true [ '資料作成', '備品発注' ]
expose に書いた reset だけが関数として見え、hidden と count は undefined になりました。v-for に付けた rows が配列であることも確認できます。
子の公開範囲のまとめ
| 子の書き方 | 親から見えるもの | 見せ方 |
|---|---|---|
<script setup> |
何も見えない($el などの組み込みは使える) | defineExpose({ ... }) に並べる |
| Options API(expose なし) | data・methods・computed などすべて | そのまま |
| Options API(expose あり) | expose に書いた名前だけ | expose: ['reset'] |
refがnullやundefinedになる4つの原因と直し方
「参照を使おうとしたら null だった」という時は、次の表のどれかに当てはまることがほとんどです。
| 症状 | 原因 | 直し方 |
|---|---|---|
setup 直下で null、Cannot read properties of null |
マウント前に読んでいる | onMounted の中に移す。イベントハンドラの中ならそのままで可 |
| 表示を切り替えた直後に null | v-if で要素がまだ無い、またはDOM更新待ち | await nextTick() のあとに読む。または watch で参照を見張る |
| いつまでも null(警告も出ない) | 名前の不一致(キー・変数名・大文字小文字) | ref 属性の値とキー(または変数名)を一字ずつ合わせる |
参照はあるのに is not a function |
子で defineExpose していない | 子に defineExpose({ メソッド名 }) を追加する |
それぞれ再現します。
原因1:onMountedより前に読んでいる
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
const box = useTemplateRef<HTMLDivElement>('box')
console.log('setup直下:', box.value)
try {
box.value!.focus()
} catch (e) {
console.log(`${(e as Error).name}: ${(e as Error).message}`)
}
onMounted(() => console.log('onMounted:', box.value?.tagName))
</script>
<template>
<div ref="box" tabindex="0">box</div>
</template>
実行結果(Vue 3.5.43):
setup直下: null
TypeError: Cannot read properties of null (reading 'focus')
onMounted: DIV
直し方:要素を触る処理を onMounted の中へ移します。 ボタンのクリックなどイベントハンドラの中は、すでにマウント後なので参照をそのまま使えます。
処理の順番を表にすると次のとおりです。
| 順番 | タイミング | 参照の値 |
|---|---|---|
| 1 | setup(<script setup> の直下) |
null |
| 2 | テンプレートの描画・DOMの作成 | ― |
| 3 | onMounted | 要素 |
| 4 | データ変更の直後 | 変更前の状態 |
| 5 | nextTick のあと / onUpdated | 変更後の状態 |
| 6 | アンマウント後 | null |
原因2:v-ifで要素が存在しない
<script setup lang="ts">
import { nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
const editing = ref(false)
const field = useTemplateRef<HTMLInputElement>('field')
watch(field, (el) => console.log('watch:', el ? el.tagName : el))
onMounted(async () => {
console.log('onMounted:', field.value)
editing.value = true
console.log('true にした直後:', field.value)
await nextTick()
console.log('nextTick後:', field.value?.tagName)
field.value?.focus()
editing.value = false
await nextTick()
console.log('false に戻した後:', field.value)
})
</script>
<template>
<input v-if="editing" ref="field" />
<span v-else>表示モード</span>
</template>
実行結果(Vue 3.5.43):
onMounted: null
true にした直後: null
watch: INPUT
nextTick後: INPUT
watch: null
false に戻した後: null
v-if が false の間は onMounted の中でも null です。 true にした直後もまだ null で、nextTick を待つと要素になります。false に戻すと再び null になりました。
直し方は2通りあります。
// 直し方A:切り替えた処理の中で nextTick を待つ
async function startEdit() {
editing.value = true
await nextTick()
field.value?.focus()
}
// 直し方B:参照そのものを watch して、要素が付いた時に処理する
watch(field, (el) => {
el?.focus()
})
「編集ボタンを押したら入力欄を出してフォーカス」のように、切り替える場所が1つなら A、複数の場所から表示が切り替わるなら B が書きやすくなります。v-show は要素を残したまま表示だけを切り替えるので、参照は null になりません(v-show での動作は今回未実行で、一般に知られた仕様として記載しています)。
原因3:名前が一致していない
<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from 'vue'
// テンプレート側は ref="nameInput"
const a = useTemplateRef<HTMLInputElement>('name-input') // キーが違う
const nameinput = ref<HTMLInputElement | null>(null) // 変数名が違う(I が小文字)
onMounted(() => {
console.log('useTemplateRef:', a.value)
console.log('ref(null):', nameinput.value)
})
</script>
<template>
<input ref="nameInput" />
</template>
実行結果(Vue 3.5.43):
useTemplateRef: null
ref(null): null
名前が合っていなくても、実行時には警告もエラーも出ず、静かに null のままでした。 気づきにくいので、次の点を確かめます。
| 確かめる点 | 例 |
|---|---|
| ハイフン区切りとキャメルケースが混ざっていないか | name-input と nameInput は別の名前 |
| 大文字小文字 | nameInput と nameinput は別の名前 |
ref にコロンを付けていないか |
:ref="nameInput" は変数の中身を名前として使う書き方になる |
| 3.4 以前の書き方で、変数を別名にしていないか | 変数名そのものが ref 属性の値と一致している必要がある |
型チェックを入れておくと早く気づけます。vue-tsc はテンプレートの ref 属性から型を推論するので、後述の型付けの節のように、キーと型が結び付いた状態で確認できます。
なお、:ref="key" のようにコロン付きで文字列の変数を渡した場合、変数の中身(下の例では 'second')が名前として使われました。
<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from 'vue'
const key = ref('second')
const a = useTemplateRef<HTMLElement>('first')
const b = useTemplateRef<HTMLElement>('second')
onMounted(() => console.log('first:', a.value, '/ second:', b.value?.tagName))
</script>
<template>
<p :ref="key">動的な名前</p>
</template>
first: null / second: P
原因4:子でdefineExposeしていない
子(CounterNoExpose.vue):
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
// defineExpose を書いていない
</script>
<template>
<p>count: {{ count }}</p>
</template>
親:
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
import CounterNoExpose from './CounterNoExpose.vue'
const counter = useTemplateRef('counter')
onMounted(() => {
console.log('参照そのもの:', counter.value === null ? 'null' : 'あり')
console.log('increment:', typeof (counter.value as any).increment)
try {
;(counter.value as any).increment()
} catch (e) {
console.log(`${(e as Error).name}: ${(e as Error).message}`)
}
})
</script>
<template>
<CounterNoExpose ref="counter" />
</template>
実行結果(Vue 3.5.43):
参照そのもの: あり
increment: undefined
TypeError: counter.value.increment is not a function
参照そのものは取れているのに、メソッドが undefined になるのがこのケースの見分け方です。直し方は、子に1行足すだけです。
defineExpose({ increment })
TypeScriptでの型付け(コピペ用)
DOM要素の型
import { ref, useTemplateRef } from 'vue'
// Vue 3.5 以降:ジェネリクスに要素の型を渡す(.value は「要素 または null」になる)
const inputEl = useTemplateRef<HTMLInputElement>('name-input')
// Vue 3.4 以前:null を含めた型を自分で書く
const nameInput = ref<HTMLInputElement | null>(null)
// v-for に付けた時は配列
const itemEls = useTemplateRef<HTMLLIElement[]>('items')
よく使う要素の型は次のとおりです。
| 要素 | 型 | その型で使える例 |
|---|---|---|
<input> |
HTMLInputElement | focus()、select()、value、files |
<textarea> |
HTMLTextAreaElement | focus()、value、scrollHeight |
<select> |
HTMLSelectElement | value、selectedIndex |
<form> |
HTMLFormElement | reset()、reportValidity() |
<button> |
HTMLButtonElement | focus()、disabled |
<div> |
HTMLDivElement | scrollTop、getBoundingClientRect() |
<canvas> |
HTMLCanvasElement | getContext() |
<video> |
HTMLVideoElement | play()、pause() |
<dialog> |
HTMLDialogElement | showModal()、close() |
| 種類を問わない時 | HTMLElement | focus()、scrollIntoView() |
子コンポーネントの型:InstanceType
import { useTemplateRef } from 'vue'
import Counter from './Counter.vue'
const counter = useTemplateRef<InstanceType<typeof Counter>>('counter')
counter.value?.increment() // defineExpose したものに補完が効く
typeof Counter でコンポーネントの型を取り出し、InstanceType でそのインスタンスの型に変換しています。3.4 以前なら ref<InstanceType<typeof Counter> | null>(null) と書きます。
型エラーの実例
次のコードを vue-tsc 3.3.12(TypeScript 6.0.3、strict)にかけました。
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
import Counter from './Counter.vue'
const inputEl = useTemplateRef<HTMLInputElement>('name-input')
const auto = useTemplateRef('name-input')
const counter = useTemplateRef<InstanceType<typeof Counter>>('counter')
const autoCounter = useTemplateRef('counter')
onMounted(() => {
inputEl.value.focus() // (1) null の可能性
inputEl.value?.focus() // OK
const n: number = auto.value // (2) 推論された型を表示させる
counter.value?.increment() // OK
counter.value?.secret // (3) 公開していない
const m: number = autoCounter.value // (4) 推論された型を表示させる
const c: number = counter.value!.count // OK: ref はほどかれて number
})
</script>
<template>
<input ref="name-input" />
<Counter ref="counter" />
</template>
実行結果(npx vue-tsc --noEmit):
src/TypeCheck.vue(11,3): error TS18047: 'inputEl.value' is possibly 'null'.
src/TypeCheck.vue(13,9): error TS2322: Type 'HTMLInputElement | null' is not assignable to type 'number'.
Type 'null' is not assignable to type 'number'.
src/TypeCheck.vue(15,18): error TS2339: Property 'secret' does not exist on type 'CreateComponentPublicInstanceWithMixins<ToResolvedProps<{}, {}>, { count: Ref<number, number>; increment: () => void; reset: () => void; }, {}, {}, {}, ComponentOptionsMixin, ... 19 more ..., {}>'.
src/TypeCheck.vue(16,9): error TS2322: Type 'CreateComponentPublicInstanceWithMixins<ToResolvedProps<{}, {}>, { count: Ref<number, number>; increment: () => void; reset: () => void; }, {}, {}, {}, ComponentOptionsMixin, ... 19 more ..., {}> | null' is not assignable to type 'number'.
Type 'null' is not assignable to type 'number'.
ここから分かることを表にまとめます。
| 番号 | エラー | 意味 | 対応 |
|---|---|---|---|
| (1) | TS18047 possibly 'null' | 参照は null の可能性がある型になっている | ?. を付ける、または if で確かめる |
| (2) | 型が HTMLInputElement または null と表示 | ジェネリクスなしでも、テンプレートから要素の型が推論されている | 型引数は省略してもよい |
| (3) | TS2339 Property 'secret' does not exist | defineExpose していないものは型にも現れない | 子で公開するか、使わない |
| (4) | 型に count・increment・reset が並ぶ | 子コンポーネントも自動で推論されている | 型引数は省略してもよい |
vue-tsc を使っているなら、useTemplateRef('name-input') と書くだけで型が付きます。 ジェネリクスを明示するのは、エディタの設定に左右されず型をはっきり示したい時や、動的な ref で推論が効かない時です。
Options API では $refs の各プロパティに型が付かないため、this.$refs.nameInput as HTMLInputElement のように as で指定します。なお、型チェックで確認したのは上記の <script setup> のサンプルです。
useTemplateRefを自作の関数にまとめる
useTemplateRef は、呼び出したコンポーネントのテンプレートからキーを探します。そのため、自作の関数(コンポーザブル)の中で呼んでも動きます。
useAutoFocus.ts:
import { onMounted, useTemplateRef } from 'vue'
// 呼び出したコンポーネントのテンプレートから、key の要素を探してフォーカスする
export function useAutoFocus(key: string) {
const el = useTemplateRef<HTMLElement>(key)
onMounted(() => el.value?.focus())
return el
}
使う側(SearchBox.vue):
<script setup lang="ts">
import { useAutoFocus } from './useAutoFocus'
useAutoFocus('keyword')
</script>
<template>
<input ref="keyword" placeholder="検索語" />
</template>
実行結果(Vue 3.5.43):
activeElement: INPUT 検索語
3.4 以前の書き方では「変数名を ref 属性と合わせる」必要があるため、こうした共通化では参照を引数で受け渡す形になります。キーの文字列だけで済むのは useTemplateRef の利点です。
テンプレート参照を使う場面の判断表
テンプレート参照は、Vue のデータの流れの外側で要素を直接操作する手段です。props やイベントで表せることはそちらで書くと、データと画面の対応が追いやすくなります。
| やりたいこと | 向いている手段 | 理由 |
|---|---|---|
| フォーカス、テキスト選択、スクロール | テンプレート参照 | DOM のメソッドを呼ぶ操作で、データでは表せない |
| 要素の幅・高さ・位置を測る | テンプレート参照 | getBoundingClientRect() などを呼ぶ必要がある |
| canvas への描画、video の再生 | テンプレート参照 | 要素固有の API を使う |
| 外部のライブラリ(グラフ、地図、エディタ)に要素を渡す | テンプレート参照 | ライブラリが DOM 要素を要求する |
| 入力値を読む・書く | v-model | データと入力欄が自動で同期する |
| 表示・非表示、クラスの切り替え | v-if / v-show / :class | データを変えれば画面が追従する |
| 子に値を渡す | props | 流れが親から子へ一方向で読みやすい |
| 子から親へ知らせる | emit | 子が親の中身を知らずに済む |
| 親から子の動作を起こす(フォームのリセット、モーダルを開くなど) | defineExpose とテンプレート参照 | 「命令」を1回送る用途に合う |
目安は「状態はデータで、命令は参照で」です。 開閉の状態のようにデータで表せるものは props や v-model に寄せ、focus() や reset() のような一度きりの動作にテンプレート参照を使うと、役割が分かれて見通しがよくなります。
書き終えたら見るチェックリスト
- [ ] 要素を触る処理は onMounted の中、またはイベントハンドラの中に書いた
- [ ] 参照を読む箇所は
?.か if で null を考慮した - [ ] ref 属性の値とキー(3.4 以前は変数名)が一字ずつ一致している
- [ ]
refと:refを取り違えていない(文字列の名前はコロンなし、関数はコロン付き) - [ ] v-if の要素は、表示を切り替えたあと nextTick を待ってから触っている
- [ ] v-for の参照配列を、データと同じ並びだと仮定していない
- [ ] 子のメソッドを呼ぶ時、子に defineExpose を書いた
- [ ] 公開するものは必要な分だけにした
よくある質問
Q. ref() と useTemplateRef() は何が違いますか? A. ref() は値を入れておくリアクティブな入れ物、useTemplateRef() はテンプレート上の要素や子コンポーネントを受け取るための専用の関数です。3.4 以前は ref(null) が両方の役目を兼ねていましたが、3.5 以降は useTemplateRef を使うと「これは要素の参照」とコード上で区別できます。3.4.38 には useTemplateRef が export されていないことを確認しました。
Q. Vue 3.5 に上げたら、ref(null) の書き方は書き換えが必要ですか? A. そのままで動きます。この記事でも 3.5.43 上で ref(null) の書き方がフォーカスできることを確かめました。新しく書く部分から useTemplateRef にしていく進め方で十分です。
Q. 子コンポーネントの参照から、子の DOM 要素を取れますか?
A. counter.value?.$el で子のルート要素を取得できます。defineExpose の有無に関係なく使えました。子の内側の特定の要素を親から触りたい場合は、子の側でその要素を操作するメソッド(例:focus)を用意し、defineExpose で公開する形にすると、子の構造が変わっても親を直さずに済みます。
Q. 参照が変わった時に処理を走らせるには? A. 参照そのものを watch に渡します。v-if の再現コードでは、要素が付いた時に INPUT、外れた時に null がコールバックへ渡りました。
まとめ
- Vue 3.5 以降は useTemplateRef('キー')、3.4 以前は同じ名前の ref(null)、Options API は this.$refs.キー で要素を受け取る
- 参照は onMounted 以降に使う。 setup 直下では null(Options API の created では undefined)だった
- v-for に付けると配列になる。 並びはデータと一致しないことがあるので、行を狙う時は関数 ref と Map を使う
- 子のメソッドを呼ぶには、子で defineExpose する。 書いていないと参照は取れてもメソッドが undefined になる
- v-if の要素は、切り替えたあと nextTick を待つか、参照を watch する
- 名前の不一致は警告なしで null になる。 ハイフンとキャメルケース、大文字小文字、コロンの有無を確かめる
- 型は HTMLInputElement などの要素型と InstanceType で付ける。 vue-tsc を使っていれば型引数なしでも推論される
参考・出典
- Vue.js 公式ドキュメント「テンプレート参照」 https://ja.vuejs.org/guide/essentials/template-refs
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。