A Vue template is not sent to the browser as HTML. The compiler turns it into a render function: JavaScript that returns a description of what to draw. You can skip the template and write that function yourself, either with the h() helper or with JSX.
Writing it yourself is the clearer choice when the structure of the output comes from data, such as a form generated from a schema. Most of the time it is not: a template stays close to HTML, and the compiler adds optimisations that a hand-written function goes without. The examples use Vue 3.5 and Vite 8.
What h() returns
h() creates a vnode, short for virtual node: a plain JavaScript object that describes one element or component. It takes a type, optional props and optional children:
import { h } from 'vue'
const vnode = h('p', { id: 'intro', class: 'lead' }, 'Hello')
vnode.type // 'p'
vnode.props // { id: 'intro', class: 'lead' }
vnode.children // 'Hello'
vnode.key // null
Nothing has been drawn yet. Vue's renderer takes a tree of these objects, compares it with the previous tree and changes only the parts of the real DOM that differ.
The props object accepts what a template accepts. class and style take the same strings, arrays and objects, and event listeners are props that start with on followed by a capital letter, so @click becomes onClick. Children can be a string, a single vnode or an array of both.
Rely only on the four properties shown above: type, props, children and key. The rest of a vnode is internal and can change.
A heading with a dynamic level
The classic case is a component whose tag depends on a prop, such as a heading that renders h1 to h6. With no template, the component can be a plain .ts file. It returns its render function from setup() inside defineComponent(), because <script setup> cannot return a render function:
import { defineComponent, h, type PropType } from 'vue'
export default defineComponent({
name: 'AppHeading',
props: {
level: { type: Number as PropType<1 | 2 | 3 | 4 | 5 | 6>, required: true },
},
setup(props, { slots }) {
return () => h(`h${props.level}`, slots.default?.())
},
})
The tag is built from the prop with a template literal, and the default slot provides the text. An id or class passed by the parent falls through to the heading as it would in a template. You use it like any other component: <AppHeading :level="2">Pricing</AppHeading>.
To be fair to templates, an SFC can do the same with <component :is="tag"> and a computed tag built from the level. Render functions earn their place when there is more than a tag name to compute.
Read props inside the render function
setup() runs once for each instance and returns the render function. Vue calls that function again whenever the component updates, for example when reactive state it read changes or the parent passes new props. That split causes a common bug, reading a prop in the wrong place:
setup(props, { slots }) {
const tag = `h${props.level}` // runs once, when the component is created
return () => h(tag, slots.default?.())
},
The heading renders correctly the first time. Change level from 2 to 3 in the parent and it stays an h2. setup() ran once and saved 'h2' in a plain string. Vue does run the render function again when level changes, but the function never reads props.level: it reuses the saved string.
setup(props, { slots }) {
return () => h(`h${props.level}`, slots.default?.())
},
Read props.level inside the returned function, or wrap the derived value in computed() and read tag.value there. Either way, each render starts from the current prop instead of a copy made when the component was created.
Slots in render functions
Inside a render function, slots holds one function per slot. Calling it returns an array of vnodes, and for a scoped slot you pass the slot's props as the argument. Here is a list that lets the parent decide how each item looks:
import { defineComponent, h, type PropType } from 'vue'
export default defineComponent({
name: 'DataList',
props: {
items: { type: Array as PropType<string[]>, required: true },
},
setup(props, { slots }) {
return () =>
props.items.length
? h('ul', props.items.map((text, index) =>
h('li', { key: text }, slots.item?.({ text, index }) ?? text),
))
: h('p', slots.empty?.() ?? 'Nothing here yet.')
},
})
props.items.map() plays the part of v-for, a ternary plays v-if, and key goes in the props object. The ?. and ?? fall back to plain text when the parent passes no slot. A parent template uses it as usual, with <template #item="{ text, index }">.
Passing slots the other way, from a render function to a child component, uses functions too: h(DataList, { items }, { item: ({ text }: { text: string }) => h('strong', text) }). If the child takes no props, write null as the second argument, or Vue reads the slots object as props.
Set up JSX with @vitejs/plugin-vue-jsx
JSX lets you write the same vnodes with HTML-like syntax. Install the official plugin with npm install -D @vitejs/plugin-vue-jsx (version 5.1.5 supports Vite 8) and add it next to the Vue plugin:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
export default defineConfig({
plugins: [vue(), vueJsx()],
})
TypeScript needs two options in tsconfig.json: "jsx": "preserve", so it leaves the JSX for the plugin, and "jsxImportSource": "vue", so it uses Vue's JSX types (since Vue 3.4 they are no longer registered globally). A project made with create-vue already has both, inherited from @vue/tsconfig. Components written in JSX go in .tsx files, like this search form:
import { defineComponent, ref } from 'vue'
export default defineComponent({
name: 'SearchBox',
emits: { search: (term: string) => term.length > 0 },
setup(_props, { emit }) {
const term = ref('')
return () => (
<form onSubmit={(event) => { event.preventDefault(); emit('search', term.value) }}>
<label for="q">Search the docs</label>
<input id="q" type="search" v-model={term.value} />
<button type="submit" disabled={!term.value}>Search</button>
</form>
)
},
})
The emits object declares the search event with a validator: TypeScript takes the argument's type from it, and Vue warns in development when it returns false. A render function gets no automatic ref unwrapping, so the code reads term.value wherever a template would write term.
Where Vue's JSX differs from React's
The syntax looks the same, but Vue's transform is different, so React habits do not all carry over:
- Attributes keep their HTML names. Write
classandfor, notclassNameandhtmlFor. - Directives exist. The plugin supports
v-model,v-showand custom directives registered on the component or the app. On a ref you bindv-model={term.value}, because the plugin turns it into the assignmentterm.value = $event. - Children of a component are slots, not a
childrenprop. Pass a function for the default slot, or an object of functions for named slots:<DataList items={fruits}>{{ item: ({ text }: { text: string }) => <strong>{text}</strong> }}</DataList>. - Event modifiers are part of the name:
onClickCaptureoronKeyupOnce, andwithModifiers()fromvuefor the rest. setup()runs once and only the returned function re-runs, so React's rules of hooks do not apply. State lives above thereturn, markup inside it.
Why templates are still the default
Because the compiler can read a template, it leaves hints for the runtime:
- Elements with no bindings are created once, cached and skipped on every later update.
- Each dynamic element carries a patch flag, a number saying exactly what can change on it (only the class, or only the text), so the renderer checks that and nothing else.
- Every block keeps a flat list of its dynamic descendants, so an update walks those few nodes instead of the whole tree.
A render function written by hand, with h() or JSX, gets none of this by default. Every update builds every vnode again and compares every prop. For one small component the difference does not matter. Across a large app made of render functions, it does. The JSX plugin's optimize option adds some patch flags, but it is off by default and, by its own README, less thorough than the template compiler.
Templates also stay close to HTML, work with scoped styles in the same file, and are type-checked by Vue's language tools.