Render functions and JSX
Build output in JavaScript with h() when the structure is data, such as table columns with their own cell renderers, pass slots as functions, mount a toast with render(), and know when a template is still the better tool.
What you'll learn
When you finish, you can do these.- 1Write a render function that builds a table with h()
- 2Pass a component's slots to h() as functions
- 3Mount a toast outside the app with render()
- 4Choose between a template and a render function
Picture a shop's stock page: a table with a row per product, showing its name, its price, a coloured stock badge and an Add button. An admin wants to choose which columns appear, and another page wants the same table with other columns. In a template, the HTML part of a component, every column is typed by hand. It is easier when the columns are data: a list where each column has a label and a function that returns its cell.
A render function does a template's job in JavaScript, with h(), a function from vue, so a cell can return a badge or a button as easily as text. The Code tab holds the stock page: App.vue lists the products and the columns, and DataTable.ts turns them into a table without a template. By the end you can add a column in one line. You need one idea from Slots: a component marks a spot with <slot />, and what its parent writes between its tags lands there.
What a template turns into
Vue compiles every template into a render function: a function that returns virtual nodes, plain objects that describe elements (a tag, its attributes, its children) and make up Vue's in-memory copy of the page. When the data it reads changes, Vue calls that function again, compares the new virtual nodes with the previous ones and changes only the parts of the real page (the DOM) that differ.
- Template
- Compiler
- Render functionh()
- Virtual nodes
- Patch the DOM
A render function builds each virtual node with h(type, props, children). type is a tag name or a component. props holds attributes and listeners; a listener is a prop named on plus the event, such as onClick for @click. children is a string, a virtual node or an array of them. With no props, h('th', 'Price') makes a header cell, <th>Price</th>.
A table whose columns are data
In the Code tab, the stock table is a component with no template, one file: DataTable.ts. In place of <script setup>, it hands its props and a setup() function to defineComponent(), which declares a component as an object. setup() runs once and returns the render function, which Vue calls on every render, so it reads the props fresh each time and stays reactive.
export interface Column<Row> {
label: string
cell: (row: Row) => VNodeChild
}
export default defineComponent({
name: 'DataTable',
props: {
rows: { type: Array as PropType<{ id: number }[]>, required: true },
columns: { type: Array as PropType<Column<any>[]>, required: true },
},
setup(props) {
return () => h('table', [
h('thead', h('tr', props.columns.map(col => h('th', col.label)))),
h('tbody', props.rows.map(row =>
h('tr', { key: row.id }, props.columns.map(col => h('td', col.cell(row)))),
)),
])
},
})
A Column is a header label and a cell function that takes a row and returns a VNodeChild: a string, a virtual node or a list of them. .map() does the job of v-for, the template's loop, and key goes in the props as it would in a template.
The table knows nothing about products: the columns, in App.vue, render every cell. The coloured stock badge is Badge, a component in Badge.vue, and the Add button calls showToast() from toast.ts, which pops up a short notice:
const columns: Column<Product>[] = [
{ label: 'Product', cell: p => p.name },
{ label: 'Price', cell: p => `€${p.price.toFixed(2)}` },
{
label: 'Stock',
cell: p => h(Badge, { tone: p.stock === 0 ? 'out' : 'in' }, {
default: () => (p.stock === 0 ? 'Sold out' : `${p.stock} left`),
}),
},
{
label: 'Cart',
cell: p => h('button', {
disabled: p.stock === 0,
onClick: () => showToast(`${p.name} added to the cart`),
}, 'Add'),
},
]
The preview shows Mug, Teapot and Coaster, with the Teapot Sold out and its Add button greyed out. The columns are plain objects: filter them by the ones an admin picked, or hand a different list to another table.
Look at the Stock column. When h() renders a component, the third argument is its slots: the content a template would put between its tags, here the badge's text. Slots are passed as functions, in an object such as { default: () => … }, or as one function for the default slot alone. Badge calls that function during its own render, so it can update without its parent rendering again.
A render-function component gets its own slots as functions too, on slots in the second argument of setup(). slots.default?.() returns the parent's content, if any, as virtual nodes the component can wrap, filter or reorder.
Outside the template: render() and JSX
render(vnode, container), another function from vue, mounts virtual nodes into any element, outside the app. The Add button's notice, showToast() in toast.ts, uses it:
export function showToast(message: string) {
const host = document.createElement('div')
document.body.append(host)
render(h('p', { class: 'toast', role: 'status' }, message), host)
setTimeout(() => {
render(null, host)
host.remove()
}, 2000)
}
Press Add on the Mug row, and Mug added to the cart shows in the corner for two seconds, until render(null, host) unmounts it.
The toast belongs to no app, so nothing an app shares with its components reaches it: app.provide() values, plugins, global components. Markup moved with <Teleport> (Teleport and Suspense) is different: it stays part of the app.
JSX writes the same h() calls in an HTML-like syntax: the cell h('td', col.cell(row)) becomes <td>{col.cell(row)}</td>. A real project needs the Vite plugin @vitejs/plugin-vue-jsx and lang="tsx"; the Code tab does not run JSX.
Templates stay the default because the compiler marks which parts of a template can change, so on every update Vue skips the parts that never do. A hand-written render function gets none of those hints, and Vue compares all of it.
What goes wrong
The first mistake hands the Stock badge its text as an array, the way you pass children to a tag:
cell: p => h(Badge, { tone: 'in' }, [`${p.stock} left`]),
The badge shows, and the preview warns Non-function value encountered for default slot. Prefer function slots for better performance. Vue wrapped the array in a slot function for you, and the content was built in the parent's render instead of Badge's. Pass a function, and the warning goes away:
cell: p => h(Badge, { tone: 'in' }, { default: () => `${p.stock} left` }),
The second mistake is reaching for h() when a template does the job. Picture a heading whose level is a prop, written with h() as Heading.ts, a file that is not in the Code tab:
export default defineComponent({
props: { level: { type: Number, default: 1 } },
setup(props, { slots }) {
return () => h(`h${props.level}`, slots.default?.())
},
})
Nothing breaks on screen, but you wrote JavaScript a reader has to run in their head, and you gave up the compiler's hints. <component :is>, the built-in that renders whatever is names (Dynamic and async components), takes a tag name as well as a component. Heading.vue in the Code tab uses it:
<template>
<component :is="`h${level}`"><slot /></component>
</template>
In App.vue, <Heading :level="2">Shop stock</Heading> renders an <h2>.
What to remember
- Every template becomes a render function.
h(type, props, children)builds the virtual nodes it returns, and listeners are props such asonClick. - A component's children in
h()are its slots: an object of functions, or one function for the default slot. Arrays work but warn. render()mounts virtual nodes outside the app, like the toast, where noapp.provide()value or plugin reaches them.- Use
h()where the structure is data, such as table columns. Everywhere else a template is shorter and lets the compiler optimise.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add a Value column to
columnsinApp.vue:{ label: 'Value', cell: p => `€${(p.price * p.stock).toFixed(2)}` }. The table gains a column reading €114.00, €0.00 and €12.75. - Add a low-stock badge. In
Badge.vue, add'low'to thetonetype and a.low { background: lemonchiffon; color: saddlebrown; }rule. In the Stock column, pick the tone withp.stock === 0 ? 'out' : p.stock <= 5 ? 'low' : 'in'. The Coaster badge turns yellow with 3 left, and the Mug badge stays green. - In the Stock column, replace
{ default: () => … }with an array:[p.stock === 0 ? 'Sold out' : `${p.stock} left`]. The badges still show, and the preview warnsNon-function value encountered for default slot.Put the slot function back and the warning goes away.