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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?