What Render Functions Unlock
Computed structure, cloning what a slot handed you, and recursive components: the jobs a template cannot do cleanly.
cloneVNode(vnode: VNode, extraProps?: object): VNodeMost screens are better as templates. Three jobs are not, and they are the reason render functions exist.
Structure decided at runtime
In a render function the element type is just a value, so h(`h${props.level}`, ...) renders an h2 or an h4 depending on data. The same applies to a table whose columns come from an array of descriptors: the number of cells per row is not known while you are writing the code.
Changing what a slot gave you
Calling slots.default() returns the consumer's vnodes as objects. Vnodes are treated as immutable, so you do not edit them: cloneVNode(vnode, extraProps) returns a copy with extra props merged in. That is how a radio group can stamp a shared name and a checked flag onto whatever inputs the consumer wrote, instead of making every child read a provide key.
mergeProps(...objects) is the companion: it combines props objects while concatenating class, merging style, and collecting listeners with the same name into an array rather than overwriting.
Recursion
A tree of unknown depth is a function that calls itself. In a render function that is a local helper invoked for each node's children, with a key on every list item.
What trips people up
Slot children are not always the elements you see in the caller's markup.
Example
// src/components/TreeView.ts
import { defineComponent, h } from 'vue'
import type { PropType, VNode } from 'vue'
export type TreeItem = { id: string; label: string; children?: TreeItem[] }
export default defineComponent(
(props: { items: TreeItem[] }) => {
const renderItem = (item: TreeItem): VNode =>
h('li', { key: item.id }, [
h('span', { class: 'tree-label' }, item.label),
item.children?.length
? h('ul', { class: 'tree-branch' }, item.children.map(renderItem))
: null
])
return () => h('ul', { class: 'tree' }, props.items.map(renderItem))
},
{
props: {
items: { type: Array as PropType<TreeItem[]>, required: true }
}
}
)When to use it
- Render a file tree or a comment thread of unknown depth.
- Build a table or form whose fields come from a configuration object.
- Add a shared name, id or handler to every child a consumer passed into a slot.
More examples
A heading whose level is a prop
Reusable sections should not hard-code h2, because the same component may sit at two depths and heading order is what screen readers navigate by. The tag name is computed from the prop, and the slot supplies the text.
// src/components/SectionTitle.ts
import { defineComponent, h } from 'vue'
import type { PropType } from 'vue'
type Level = 1 | 2 | 3 | 4 | 5 | 6
export default defineComponent(
(props: { level: Level }, { slots }) => {
return () => h(`h${props.level}`, { class: 'section-title' }, slots.default?.())
},
{
props: {
level: { type: Number as PropType<Level>, default: 2 }
}
}
)Stamping props onto slot children
The consumer writes plain inputs inside the group; this component clones each one with the shared name, the checked state derived from modelValue, and a change handler. Use mergeProps instead of a plain object when the children already carry a class or a listener you must not overwrite.
// src/components/RadioGroup.ts
import { cloneVNode, defineComponent, h } from 'vue'
import type { VNode } from 'vue'
export default defineComponent(
(props: { name: string; modelValue: string }, { slots, emit }) => {
return () => {
const children = slots.default?.() ?? []
return h(
'fieldset',
{ class: 'radio-group' },
children.map((child: VNode) =>
cloneVNode(child, {
name: props.name,
checked: child.props?.value === props.modelValue,
onChange: () => emit('update:modelValue', String(child.props?.value))
})
)
)
}
},
{
props: {
name: { type: String, required: true },
modelValue: { type: String, required: true }
},
emits: ['update:modelValue']
}
)A table built from column descriptors
Each column carries its header and a function that turns a row into a cell string, so the same component renders an orders table and an invoices table. Every cell and row gets a key, since both lists can reorder. Writing this as a template means a nested v-for and a dynamic component per cell.
// src/components/DataTable.tsx
import { defineComponent } from 'vue'
import type { PropType } from 'vue'
type Row = Record<string, unknown>
type Column = { key: string; header: string; cell: (row: Row) => string }
export default defineComponent(
(props: { columns: Column[]; rows: Row[]; rowKey: string }) => () => (
<table class="data-table">
<thead>
<tr>
{props.columns.map((column) => (
<th key={column.key} scope="col">{column.header}</th>
))}
</tr>
</thead>
<tbody>
{props.rows.map((row) => (
<tr key={String(row[props.rowKey])}>
{props.columns.map((column) => (
<td key={column.key}>{column.cell(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
{
props: {
columns: { type: Array as PropType<Column[]>, required: true },
rows: { type: Array as PropType<Row[]>, required: true },
rowKey: { type: String, required: true }
}
}
)
Discussion