When Not to Use Them

Templates are compiled and optimised; a render function is not. The cost shows up in patching, tooling and readability.

Vue's own guidance is to use templates for the vast majority of an application. The reason is mechanical, not stylistic.

The compiler knows things the runtime cannot

Because template syntax is deterministic, Vue's compiler can analyse it ahead of time. It caches static vnodes so they are created once and reused on every later render. It attaches patch flags to dynamic nodes, marking which part changed — class only, text only — so the runtime checks a bitmask instead of comparing every prop. It also flattens each template into a block that lists only the dynamic descendants, so re-rendering walks those instead of the whole tree.

A render function is an opaque function. None of that analysis is possible, so its vnodes are built fresh on every call and diffed without hints.

The tooling cost

Scoped styles are a single-file component feature, so a .ts or .tsx component has no <style scoped> block. Template type checking understands tags and their props; h() takes its props as a loosely typed object, so a misspelled DOM prop is just another key. And a template is readable by anyone on the team, while a render function is readable by whoever likes render functions.

Where the line sits

Keep render functions at the leaves. One small component whose structure is genuinely computed, wrapped in templates on every side.

Example

Example · vue
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{ cents: number; currency: string }>()

const formatted = computed(() =>
  new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: props.currency
  }).format(props.cents / 100)
)
</script>

<template>
  <p class="price">
    <span class="price-label">Total</span>
    <span class="price-value">{{ formatted }}</span>
  </p>
</template>

<style scoped>
.price {
  display: flex;
  gap: 0.5rem;
}

.price-value {
  font-variant-numeric: tabular-nums;
}
</style>

When to use it

  • Decide whether a component that is getting hard to read in a template should become a render function, or just be split in two.
  • Explain in review why a page-level component should go back to being a template.
  • Track down why a list of rows written with h() patches more slowly than the template it replaced.

More examples

The same component as a render function

Functionally identical, and worse in three ways: the static label span is rebuilt and diffed on every render instead of being cached, the styles have to move to a global sheet or a CSS module because there is no scoped style block, and the class names are strings the editor cannot follow. Compare it with the template version above before writing one of these.

Example · ts
// src/components/PriceTag.ts
import { computed, defineComponent, h } from 'vue'

export default defineComponent(
  (props: { cents: number; currency: string }) => {
    const formatted = computed(() =>
      new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: props.currency
      }).format(props.cents / 100)
    )

    return () =>
      h('p', { class: 'price' }, [
        h('span', { class: 'price-label' }, 'Total'),
        h('span', { class: 'price-value' }, formatted.value)
      ])
  },
  {
    props: {
      cents: { type: Number, required: true },
      currency: { type: String, required: true }
    }
  }
)

A leaf that earns the render function

The number of children depends on how many times the query appears in the text, so the markup cannot be written out. As a template this needs a computed array of segments plus a v-for and a v-if; as a render function it is six lines. This is the size a render function should be.

Example · ts
// src/components/HighlightedText.ts
import { defineComponent, h } from 'vue'

export default defineComponent(
  (props: { text: string; query: string }) => {
    return () => {
      if (props.query === '') {
        return h('span', props.text)
      }

      const parts = props.text.split(props.query)
      const children = parts.flatMap((part, index) =>
        index === 0 ? [part] : [h('mark', { key: index }, props.query), part]
      )

      return h('span', children)
    }
  },
  {
    props: {
      text: { type: String, required: true },
      query: { type: String, required: true }
    }
  }
)

Templates on the outside, render function on the inside

The page structure stays in a single-file component, so it keeps scoped styles, cached static nodes and template type checking. Only the one node whose shape is computed comes from a .ts file. That division is what "keep them at the leaves" means in practice.

Example · vue
<script setup lang="ts">
import HighlightedText from './HighlightedText'

const props = defineProps<{ title: string; query: string; href: string }>()
</script>

<template>
  <li class="result">
    <h3 class="result-title">
      <a :href="props.href">
        <highlighted-text :text="props.title" :query="props.query" />
      </a>
    </h3>
  </li>
</template>

<style scoped>
.result-title {
  font-weight: 600;
}
</style>

Discussion

  • Be the first to comment on this lesson.