Typing Refs and Computeds
Annotate a ref only when inference gets it wrong, handle a value that starts null, and type a template ref to an element.
const count = ref<number>(0) // const el = useTemplateRef<HTMLInputElement>('el')A ref holds one reactive value, read and written in script through .value. A computed derives a value from refs and caches it. Both are generic, so the type usually comes from what you pass in and you annotate only when that inference is wrong.
When inference is wrong
ref('idle') is Ref<string>, which is right only if the value really can be any string. For a union, say so: ref<'idle' | 'busy'>('idle'). ref([]) infers Ref<never[]> and then rejects everything you push into it, so write ref<Tag[]>([]). A computed is inferred from its return; computed<number>(...) turns that around and reports an error inside the getter when it returns something else.
Starting null
A value that arrives later starts null: ref<Tag | null>(null). Every read then has to narrow it with ?. or a v-if, which is the reason to write it that way.
Template refs
A template ref is a reference to a real DOM element. useTemplateRef('el'), added in 3.5, returns a readonly shallow ref to the element whose ref="el" matches, typed T | null. With current tooling the element type is inferred from the template; pass a type argument when it cannot be, such as a dynamic component. The older longhand is a plain ref<HTMLInputElement | null>(null) whose variable name matches the attribute value.
What trips people up
In script a ref is read and written through .value; only the template unwraps it.
Example
<script setup lang="ts">
import { computed, onMounted, ref, useTemplateRef } from 'vue'
interface Tag {
id: number
name: string
}
const tags = ref<Tag[]>([])
const draft = ref('')
const editing = ref<Tag | null>(null)
const inputRef = useTemplateRef<HTMLInputElement>('tagInput')
const summary = computed<string>(() =>
tags.value.length === 0 ? 'no tags yet' : tags.value.map((tag) => tag.name).join(', ')
)
onMounted(() => {
inputRef.value?.focus()
})
function add(): void {
const name = draft.value.trim()
if (name === '') return
tags.value.push({ id: tags.value.length + 1, name })
draft.value = ''
}
function select(tag: Tag): void {
editing.value = tag
}
</script>
<template>
<form @submit.prevent="add">
<input ref="tagInput" v-model="draft" />
<button type="submit">Add</button>
</form>
<ul>
<li v-for="tag in tags" :key="tag.id">
<button type="button" @click="select(tag)">{{ tag.name }}</button>
</li>
</ul>
<p>{{ summary }}</p>
<p v-if="editing">Editing {{ editing.name }}</p>
</template>
<style scoped>
ul {
padding-left: 0;
list-style: none;
}
</style>When to use it
- Starting a list empty and filling it after the user adds something, without fighting never[].
- Holding a selected item that is null until the user picks one.
- Focusing or measuring an element once the component has mounted.
- Constraining a status flag to the three strings it is allowed to be.
More examples
The older template ref form, and the 3.5 one
Both forms end with a ref whose value is the element or null. The difference is where the name lives: the old form ties the attribute to a variable name, which breaks silently when you rename the variable, while useTemplateRef takes the name as a string argument. Both need the null check, because neither has an element before mount.
<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from 'vue'
// Longhand, works in every Vue 3 version: the variable name must match ref="".
const searchInput = ref<HTMLInputElement | null>(null)
// Shorthand (3.5): the attribute value is an argument, not a variable name.
const resetButton = useTemplateRef<HTMLButtonElement>('resetButton')
onMounted(() => {
searchInput.value?.focus()
})
function clear(): void {
if (searchInput.value === null) return
searchInput.value.value = ''
resetButton.value?.blur()
}
</script>
<template>
<input ref="searchInput" type="search" />
<button ref="resetButton" type="button" @click="clear">Clear</button>
</template>
<style scoped>
input {
margin-right: 0.5rem;
}
</style>Three inferences that are wrong, and the fixes
The rule is narrow: annotate when the initial value does not describe the full range of values, or does not describe it tightly enough. Empty arrays and string unions are where this bites in practice, and a misspelt status string is a bug that typed refs catch for free.
import { computed, ref } from 'vue'
interface Tag {
id: number
name: string
}
// 1. Inferred Ref<never[]>: nothing can be pushed into it.
const broken = ref([])
// broken.value.push({ id: 1, name: 'mix' })
// Argument of type '{ id: number; name: string; }' is not assignable to 'never'
const tags = ref<Tag[]>([])
// 2. Inferred Ref<string>, so any string is accepted.
const looseStatus = ref('idle')
looseStatus.value = 'idel' // typo, no error
const status = ref<'idle' | 'busy'>('idle')
// 3. ref('2020') is inferred Ref<string>, so a later number assignment fails.
const year = ref<string | number>('2020')
year.value = 2020
const count = computed<number>(() => tags.value.length)
tags.value.push({ id: 1, name: 'mix' })
console.log(count.value, status.value)
// Output: 1 idleA writable computed, in both forms
`computed(fn)` and `computed({ get: fn })` are the same thing. Reach for the long form when a value needs to be written as well as read, for example when a parent binds v-model to something you store in two refs. Assigning to the short form is a type error, and in the development build it also logs a warning.
import { computed, ref } from 'vue'
const firstName = ref('Ada')
const lastName = ref('Lovelace')
// Shorthand: a getter function is sugar for { get }, and the result is readonly.
const readonlyName = computed(() => `${firstName.value} ${lastName.value}`)
// Longhand: the options object. Adding set makes the computed writable.
const fullName = computed<string>({
get: () => `${firstName.value} ${lastName.value}`,
set(value: string) {
const [first = '', last = ''] = value.split(' ')
firstName.value = first
lastName.value = last
}
})
fullName.value = 'Grace Hopper'
console.log(firstName.value, readonlyName.value)
// Output: Grace Grace Hopper
Discussion