Beyond Templates: Render Functions and JSX in Vue 2
Vue 2 templates compile down to render functions, and writing those functions directly gives you the full power of JavaScript when template syntax cannot express what you need.
Vue 2 recommends templates for the vast majority of components, and that recommendation holds in most cases. Templates are declarative, familiar, and statically analysable by tooling. But templates are a constrained syntax, and there are component designs where the constraints become obstacles. Understanding what templates compile to, and how to write that output directly, gives you a tool for the cases where the declarative layer is not sufficient.
Templates Compile to Render Functions
Every Vue 2 template is compiled into a render function before it runs in the browser. The template syntax is a convenience layer; the render function is what Vue actually calls during each component update cycle. You can write a render function yourself instead of a template, and Vue will use it directly, skipping the compilation step.
The render function receives a single argument, conventionally named h, which is createElement. It must return a single VNode, the virtual DOM representation of the component’s output. Vue then reconciles that VNode tree against the previous one and applies the minimal set of DOM mutations required.
export default {
render(h) {
return h('p', 'Hello from a render function')
}
}
This produces exactly what <template><p>Hello from a render function</p></template> would produce. The two approaches are equivalent at runtime.
The createElement Function
createElement accepts three arguments. The first is required and identifies what to create: a string tag name such as 'div', a component options object, or an async function that resolves to one of those. The second argument is an optional data object that configures the element. The third is an optional array of child VNodes, or a plain string for text content.
The data object maps to the familiar template attributes and directives. attrs holds standard HTML attributes, props holds component props, domProps holds DOM properties such as innerHTML, on holds event handlers, class and style accept the same formats as their template equivalents, and key and ref serve the same purpose they do in templates.
h('a', {
attrs: { href: '#introduction' },
class: { 'is-active': this.isActive },
on: { click: this.handleClick }
}, 'Introduction')
One constraint applies: every VNode in the tree must be unique. You cannot reuse the same VNode object in two positions. If you need repeated identical nodes, produce each one with a separate createElement call.
Where Templates Fall Short
The clearest case for a render function is a component whose root element type must be determined at runtime from a prop. A heading component that renders h1 through h6 depending on a level prop cannot express that cleanly in a template. The template syntax would require six v-if branches or a <component :is> tag that still needs supporting logic. The render function states it directly.
Vue.component('dynamic-heading', {
props: {
level: { type: Number, required: true }
},
render(h) {
return h('h' + this.level, this.$slots.default)
}
})
Complex conditional structures are another case. When the same data drives multiple nested conditions that determine not just content but structure, v-if and v-else chains in a template become hard to follow. A render function uses plain JavaScript if statements and returns from whichever branch applies. The logic is not embedded in attribute syntax; it is just code.
Directives like v-model and event modifiers such as .prevent or .stop also have no special status in a render function. v-model is implemented manually by binding value to domProps and wiring an input handler that emits the new value. Event modifiers are replaced by their equivalent DOM method calls inside the handler.
render(h) {
const self = this
return h('input', {
domProps: { value: self.currentValue },
on: {
input(event) {
self.$emit('input', event.target.value)
}
}
})
}
JSX as Syntactic Sugar
Writing deeply nested h() calls by hand is verbose. JSX addresses this by providing a syntax that resembles HTML but compiles to createElement calls. A Babel plugin transforms JSX in Vue component files, so the render function can be written in a form that is easier to read when the VNode tree is large.
import AnchoredHeading from './AnchoredHeading.vue'
new Vue({
el: '#demo',
render(h) {
return (
<AnchoredHeading level={1}>
<span>Hello</span> world!
</AnchoredHeading>
)
}
})
With version 3.4.0 of the Babel plugin, h is injected automatically in methods and getters that contain JSX, so the parameter does not need to be declared manually. JSX does not add any runtime capability; it is a transformation that produces the same createElement calls you would write by hand. The choice between the two is a readability preference.
Functional Components
A functional component is a component with no instance, no reactive data, and no lifecycle hooks. It is declared by setting functional: true in the component options. Because there is no component instance, the render function receives a context object as its second argument in place of this.
Vue.component('smart-list', {
functional: true,
props: {
items: { type: Array, required: true },
isOrdered: Boolean
},
render(h, context) {
const items = context.props.items
let component
if (items.length === 0) component = EmptyList
else if (typeof items[0] === 'object') component = TableList
else if (context.props.isOrdered) component = OrderedList
else component = UnorderedList
return h(component, context.data, context.children)
}
})
The context object provides props, children, slots(), data, parent, listeners, and injections. Passing context.data directly to a child element forwards all attributes, classes, and event listeners without listing them individually, which makes functional components well suited to thin wrapper elements.
Because there is no instance, functional components do not track watchers and do not participate in the component lifecycle. They are cheaper to render than stateful components, and their output is determined entirely by their inputs.
The Tradeoffs
Render functions and JSX give you the full expressive range of JavaScript where templates impose structured constraints. That power comes with costs. Templates are analysed at compile time for unused variables, missing props, and type errors by tools such as Vetur and the Vue Language Features extension. Render functions written in plain JavaScript receive no such analysis. Template syntax is also familiar to developers coming from HTML-based backgrounds, while render functions require comfort with the virtual DOM model.
The appropriate use of render functions is narrow. When the component’s structure genuinely cannot be expressed without dynamic tag selection, when a library component must remain fully generic, or when a functional wrapper needs to pass all context transparently to a child, the render function is the right tool. For everything else, the template is clearer, better tooled, and easier to maintain.
What You Can Do Now
Write the dynamic-heading component from scratch in a single HTML file with Vue 2 loaded from a CDN, then extend it to pass an anchor link through a scoped slot.
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
<dynamic-heading :level="2">Section Title</dynamic-heading>
<dynamic-heading :level="4">Subsection</dynamic-heading>
</div>
<script>
Vue.component('dynamic-heading', {
functional: true,
props: {
level: { type: Number, required: true }
},
render(h, context) {
return h(
'h' + context.props.level,
context.data,
context.children
)
}
})
new Vue({ el: '#app' })
</script>
</body>
</html>
From this base, replace functional: true with a stateful version that tracks how many times the heading has been rendered, by adding a data function and a mounted hook. The difference in context access, this versus the context argument, becomes concrete immediately. Then add a v-model-equivalent by wiring domProps.value and an on.input handler on an input element in the same file to observe how directive behaviour is decomposed into its constituent parts.