Rendering, Render Functions, and Web Components

This section documents the current Vue 3.x release line as published at the official Vue.js documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. The Composition API with <script setup> is the authoring style used throughout; the Options API is shown only as a contrast.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Vue and its ecosystem iterate quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Templates are the recommended way to author Vue components, but they are a convenience: the compiler turns each one into a JavaScript render function that returns a tree of virtual DOM nodes. This page explains that pipeline, when to write a render function by hand instead, and how Vue interoperates with native Web Components in both directions.

The virtual DOM and the render pipeline

A component’s render function returns VNodes — plain objects describing what the DOM should look like. On the first render Vue mounts that tree, creating real DOM nodes. On every update it produces a new VNode tree and patches: it walks old and new trees together and touches only the DOM that actually changed. See Rendering Mechanism.

flowchart LR tpl["<template>"] -->|"compile (build step
or runtime)"| render["render() function"] render -->|"call on each render"| vtree["VNode tree"] vtree -->|"first render"| mount["mount → create real DOM"] vtree -->|"subsequent renders"| patch["patch: diff old vs. new"] patch --> dom["Real DOM (minimal writes)"] mount --> dom hints["Compiler hints:
static hoisting • patch flags • tree flattening"] -.->|"attached to VNodes,
read during patch"| patch

Compiler-informed virtual DOM

Because Vue’s compiler sees the template, it annotates the render function with hints that a framework-agnostic VDOM (React’s, for example) cannot derive. This is why Vue rarely needs manual memoisation. The Compiler-Informed Virtual DOM section covers all three:

  • Static hoisting — elements with no dynamic bindings are created once, outside the render function, and reused by reference on every render. Whole static subtrees are hoisted as one.

  • Patch flags — a dynamic element is tagged with a bitmask of what can change (CLASS, STYLE, PROPS, TEXT, …​). During patch Vue checks only the flagged aspects instead of comparing every attribute.

  • Tree flattening — each block (a template, or a v-if / v-for branch) keeps a flat array of only its dynamic descendants, so patching skips the stable structure entirely and iterates a short list.

You can inspect the output on the Template Explorer.

v-once and v-memo

These push the same idea further when profiling shows a hot spot:

  • v-once renders a subtree a single time and treats it as static forever after.

  • v-memo takes a dependency array and skips re-rendering the subtree while every entry is unchanged — useful on a large v-for row where only a few items update.

<template>
  <header v-once>{{ appName }} -- built {{ buildDate }}</header>

  <div v-for="item in list" :key="item.id" v-memo="[item.id === selectedId]">
    <ExpensiveRow :item="item" :selected="item.id === selectedId" />
  </div>
</template>

Render functions

h() (hyperscript) creates a VNode: h(type, props, children). Return it from a render option or from setup(). See Render Functions & JSX.

import { h, ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    // setup() may return a render function directly
    return () =>
      h('button', { class: 'counter', onClick: () => count.value++ }, `count is ${count.value}`)
  },
}

VNode shape and the arguments

  • type — a tag string ('div'), a component object/definition, or Fragment / Text / Comment.

  • props — attributes, DOM properties, class / style (arrays and objects allowed), and onXxx event listeners. h('input', \{ onInput: e ⇒ …​ }).

  • children — a string, an array of VNodes, or an object of slot functions: h(Comp, null, \{ default: () ⇒ h('span', 'hi'), header: () ⇒ 'Title' }).

VNodes must be unique — render the same node twice by wrapping it in a factory (() ⇒ h(…​)) or by cloning.

Template features as plain JavaScript

There are no directives in a render function; use language constructs:

import { h } from 'vue'

function render() {
  return h('ul', [
    // v-if / v-else -> ternary or if/else
    this.loading
      ? h('li', 'Loading...')
      // v-for -> .map()
      : this.items.map(item => h('li', { key: item.id }, item.label)),
  ])
}

v-model is sugar for a prop plus an update listener, so pass them explicitly: h(TextField, \{ modelValue: text.value, 'onUpdate:modelValue': v ⇒ (text.value = v) }). Built-in directives that have no plain-JS form (v-show, custom directives) are applied with withDirectives.

JSX

With @vue/babel-plugin-jsx (bundled by `create-vue’s JSX option) you can write the same tree as JSX, which many find more readable for markup-heavy output:

import { ref } from 'vue'

export default function Counter() {
  const count = ref(0)
  return () => (
    <button class="counter" onClick={() => count.value++}>
      count is {count.value}
    </button>
  )
}

Vue’s JSX differs from React’s: use class (not className), onClick maps to a native listener, and v-model is available as v-model=\{[text.value, 'modelValue']}.

Functional components

A plain function (props, context) ⇒ VNode is a functional component — no instance, no reactive state of its own, just props in and VNodes out. Declare props and emits as properties on the function for runtime validation:

function Divider(props) {
  return h('hr', { class: ['divider', props.vertical && 'divider--vertical'] })
}
Divider.props = ['vertical']

When a render function beats a template

Reach for h() / JSX when the output structure is computed rather than declared: a component that renders a different element based on a level prop, a recursive tree renderer, a library component that forwards arbitrary slots, or a higher-order wrapper. For ordinary UI, templates compile to faster code thanks to the hints above.

Vue and Web Components

Custom elements (<my-widget>) and Vue components are different technologies that can be used together. See Vue and Web Components.

Using custom elements in Vue

Tell the compiler which tags are not Vue components so it renders them as elements and does not warn. With create-vue/Vite this goes in vite.config.js:

import vue from '@vitejs/plugin-vue'

vue({
  template: {
    compilerOptions: {
      // treat every tag containing a dash (except your own) as a custom element
      isCustomElement: tag => tag.includes('-'),
    },
  },
})

Then bind to it. Vue sets a value as a DOM property when the element already has a property of that name, otherwise as an attribute; force one with the .prop / .attr modifiers. Listen with @:

<template>
  <video-player
    :src="url"
    :.currentTime="seek"
    muted
    @timeupdate="onTime"
  />
</template>

Because attributes are strings, pass objects and arrays via a property binding (:.config="cfg"), not an attribute.

Building custom elements with defineCustomElement

defineCustomElement takes the same options as a normal Vue component (or an imported SFC) and returns a constructor for customElements.define:

import { defineCustomElement } from 'vue'
import MyWidget from './MyWidget.ce.vue'

const MyWidgetElement = defineCustomElement(MyWidget)
customElements.define('my-widget', MyWidgetElement)

Inside such a component:

  • Props declared on the component become observed attributes; number/boolean props are cast from the string attribute automatically.

  • emit('change', payload) dispatches a real CustomEvent named change.

  • useHost() returns the host element; useShadowRoot() returns its shadow root (for focus management, measuring, or slot inspection).

  • Styles from <style> are inlined into the shadow root. Use .ce.vue (or the *.ce.vue convention) so the build injects styles as a string rather than a <link>. Shared design tokens must be CSS custom properties, since the shadow boundary blocks outside stylesheets.

import { defineCustomElement, useHost, h, ref, onMounted } from 'vue'

const Stepper = defineCustomElement({
  props: { min: Number, max: Number },
  setup(props, { emit }) {
    const host = useHost()
    const value = ref(props.min ?? 0)
    onMounted(() => host.setAttribute('role', 'spinbutton'))
    function bump(delta) {
      value.value = Math.min(props.max, Math.max(props.min, value.value + delta))
      emit('change', value.value)
    }
    return () => h('div', [
      h('button', { onClick: () => bump(-1) }, '-'),
      h('span', value.value),
      h('button', { onClick: () => bump(1) }, '+'),
    ])
  },
})
customElements.define('x-stepper', Stepper)

Vue components vs. custom elements

Custom elements are the right unit when the consumer is framework-agnostic — a design-system library used by teams on different stacks, or widgets embedded in server-rendered HTML. They pay a cost: no build-time template optimisation across the boundary, string-only attributes, styling isolated by the shadow DOM, and extra care for SSR (the element upgrades only after its script runs on the client). For an app that is Vue end to end, plain .vue components are simpler, faster, and fully typed.

See also