---
title: "Render functions and JSX: when templates are not enough · vuejs.school"
description: "Learn what h() returns, build a heading with a dynamic level, handle props and slots in render functions, set up JSX with Vite, and know when a template wins."
canonical_url: "https://vuejs.school/community/blog/render-functions-and-jsx"
meta:
  "og:description": "Learn what h() returns, build a heading with a dynamic level, handle props and slots in render functions, set up JSX with Vite, and know when a template wins."
  "og:image": "https://vuejs.school/_og/d/a_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaA%2Cdescription_~TGVhcm4gd2hhdCBoKCkgcmV0dXJucywgYnVpbGQgYSBoZWFkaW5nIHdpdGggYSBkeW5hbWljIGxldmVsLCBoYW5kbGUgcHJvcHMgYW5kIHNsb3RzIGluIHJlbmRlciBmdW5jdGlvbnMsIHNldCB1cCBKU1ggd2l0aCBWaXRlLCBhbmQga25vdyB3aGVuIGEgdGVtcGxhdGUgd2lucy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZW5kZXItZnVuY3Rpb25zLWFuZC1qc3gi%2Cs_UEybevVKooqut1oW.png"
  "og:image:alt": "Render functions and JSX: when templates are not enough · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Render functions and JSX: when templates are not enough · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaA%2Cdescription_~TGVhcm4gd2hhdCBoKCkgcmV0dXJucywgYnVpbGQgYSBoZWFkaW5nIHdpdGggYSBkeW5hbWljIGxldmVsLCBoYW5kbGUgcHJvcHMgYW5kIHNsb3RzIGluIHJlbmRlciBmdW5jdGlvbnMsIHNldCB1cCBKU1ggd2l0aCBWaXRlLCBhbmQga25vdyB3aGVuIGEgdGVtcGxhdGUgd2lucy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZW5kZXItZnVuY3Rpb25zLWFuZC1qc3gi%2Cs_UEybevVKooqut1oW.png"
  "twitter:image:alt": "Render functions and JSX: when templates are not enough · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## What h() returns

`h()` creates a vnode, short for virtual node: a plain JavaScript object that describes one element or component. It takes a type, optional props and optional children:

```
import { h } from 'vue'

const vnode = h('p', { id: 'intro', class: 'lead' }, 'Hello')

vnode.type // 'p'
vnode.props // { id: 'intro', class: 'lead' }
vnode.children // 'Hello'
vnode.key // null
```

Nothing has been drawn yet. Vue's renderer takes a tree of these objects, compares it with the previous tree and changes only the parts of the real DOM that differ.

The props object accepts what a template accepts. `class` and `style` take the same strings, arrays and objects, and event listeners are props that start with `on` followed by a capital letter, so `@click` becomes `onClick`. Children can be a string, a single vnode or an array of both.

Rely only on the four properties shown above: `type`, `props`, `children` and `key`. The rest of a vnode is internal and can change.

## A heading with a dynamic level

The classic case is a component whose tag depends on a prop, such as a heading that renders `h1` to `h6`. With no template, the component can be a plain `.ts` file. It returns its render function from `setup()` inside `defineComponent()`, because `<script setup>` cannot return a render function:

*src/components/AppHeading.ts*

```
import { defineComponent, h, type PropType } from 'vue'

export default defineComponent({
  name: 'AppHeading',
  props: {
    level: { type: Number as PropType<1 | 2 | 3 | 4 | 5 | 6>, required: true },
  },
  setup(props, { slots }) {
    return () => h(`h${props.level}`, slots.default?.())
  },
})
```

The tag is built from the prop with a template literal, and the default slot provides the text. An `id` or `class` passed by the parent falls through to the heading as it would in a template. You use it like any other component: `<AppHeading :level="2">Pricing</AppHeading>`.

To be fair to templates, an SFC can do the same with `<component :is="tag">` and a computed `tag` built from the level. Render functions earn their place when there is more than a tag name to compute.

## Read props inside the render function

`setup()` runs once for each instance and returns the render function. Vue calls that function again whenever the component updates, for example when reactive state it read changes or the parent passes new props. That split causes a common bug, reading a prop in the wrong place:

*src/components/AppHeading.ts**Breaks***

```
setup(props, { slots }) {
  const tag = `h${props.level}` // runs once, when the component is created
  return () => h(tag, slots.default?.())
},
```

The heading renders correctly the first time. Change `level` from 2 to 3 in the parent and it stays an `h2`. `setup()` ran once and saved `'h2'` in a plain string. Vue does run the render function again when `level` changes, but the function never reads `props.level`: it reuses the saved string.

*src/components/AppHeading.ts**Works***

```
setup(props, { slots }) {
  return () => h(`h${props.level}`, slots.default?.())
},
```

Read `props.level` inside the returned function, or wrap the derived value in `computed()` and read `tag.value` there. Either way, each render starts from the current prop instead of a copy made when the component was created.

## Slots in render functions

Inside a render function, `slots` holds one function per slot. Calling it returns an array of vnodes, and for a scoped slot you pass the slot's props as the argument. Here is a list that lets the parent decide how each item looks:

*src/components/DataList.ts*

```
import { defineComponent, h, type PropType } from 'vue'

export default defineComponent({
  name: 'DataList',
  props: {
    items: { type: Array as PropType<string[]>, required: true },
  },
  setup(props, { slots }) {
    return () =>
      props.items.length
        ? h('ul', props.items.map((text, index) =>
            h('li', { key: text }, slots.item?.({ text, index }) ?? text),
          ))
        : h('p', slots.empty?.() ?? 'Nothing here yet.')
  },
})
```

`props.items.map()` plays the part of `v-for`, a ternary plays `v-if`, and `key` goes in the props object. The `?.` and `??` fall back to plain text when the parent passes no slot. A parent template uses it as usual, with `<template #item="{ text, index }">`.

Passing slots the other way, from a render function to a child component, uses functions too: `h(DataList, { items }, { item: ({ text }: { text: string }) => h('strong', text) })`. If the child takes no props, write `null` as the second argument, or Vue reads the slots object as props.

## Set up JSX with @vitejs/plugin-vue-jsx

JSX lets you write the same vnodes with HTML-like syntax. Install the official plugin with `npm install -D @vitejs/plugin-vue-jsx` (version 5.1.5 supports Vite 8) and add it next to the Vue plugin:

*vite.config.ts*

```
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

export default defineConfig({
  plugins: [vue(), vueJsx()],
})
```

TypeScript needs two options in `tsconfig.json`: `"jsx": "preserve"`, so it leaves the JSX for the plugin, and `"jsxImportSource": "vue"`, so it uses Vue's JSX types (since Vue 3.4 they are no longer registered globally). A project made with `create-vue` already has both, inherited from `@vue/tsconfig`. Components written in JSX go in `.tsx` files, like this search form:

*src/components/SearchBox.tsx*

```
import { defineComponent, ref } from 'vue'

export default defineComponent({
  name: 'SearchBox',
  emits: { search: (term: string) => term.length > 0 },
  setup(_props, { emit }) {
    const term = ref('')
    return () => (
      <form onSubmit={(event) => { event.preventDefault(); emit('search', term.value) }}>
        <label for="q">Search the docs</label>
        <input id="q" type="search" v-model={term.value} />
        <button type="submit" disabled={!term.value}>Search</button>
      </form>
    )
  },
})
```

The `emits` object declares the `search` event with a validator: TypeScript takes the argument's type from it, and Vue warns in development when it returns false. A render function gets no automatic ref unwrapping, so the code reads `term.value` wherever a template would write `term`.

## Where Vue's JSX differs from React's

The syntax looks the same, but Vue's transform is different, so React habits do not all carry over:

- Attributes keep their HTML names. Write `class` and `for`, not `className` and `htmlFor`.
- Directives exist. The plugin supports `v-model`, `v-show` and custom directives registered on the component or the app. On a ref you bind `v-model={term.value}`, because the plugin turns it into the assignment `term.value = $event`.
- Children of a component are slots, not a `children` prop. Pass a function for the default slot, or an object of functions for named slots: `<DataList items={fruits}>{{ item: ({ text }: { text: string }) => <strong>{text}</strong> }}</DataList>`.
- Event modifiers are part of the name: `onClickCapture` or `onKeyupOnce`, and `withModifiers()` from `vue` for the rest.
- `setup()` runs once and only the returned function re-runs, so React's rules of hooks do not apply. State lives above the `return`, markup inside it.

## Why templates are still the default

Because the compiler can read a template, it leaves hints for the runtime:

- Elements with no bindings are created once, cached and skipped on every later update.
- Each dynamic element carries a patch flag, a number saying exactly what can change on it (only the class, or only the text), so the renderer checks that and nothing else.
- Every block keeps a flat list of its dynamic descendants, so an update walks those few nodes instead of the whole tree.

A render function written by hand, with `h()` or JSX, gets none of this by default. Every update builds every vnode again and compares every prop. For one small component the difference does not matter. Across a large app made of render functions, it does. The JSX plugin's `optimize` option adds some patch flags, but it is off by default and, by its own README, less thorough than the template compiler.

Templates also stay close to HTML, work with scoped styles in the same file, and are type-checked by Vue's language tools.

**Check it stuck**3 questions from the Render functions and JSX quiz.

[**Practice now**](https://vuejs.school/learn/advanced/render-functions-and-jsx?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**Vue 3.6 and Vapor Mode: where things stand**](https://vuejs.school/community/blog/vue-3-6-and-vapor-mode-where-things-stand) [Deep dive · 7 min read**Vapor Mode, explained without the hype**](https://vuejs.school/community/blog/vapor-mode-explained-without-the-hype)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/render-functions-and-jsx#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-04-21","description":"Learn what h() returns, build a heading with a dynamic level, handle props and slots in render functions, set up JSX with Vite, and know when a template wins.","name":"Render functions and JSX: when templates are not enough","url":"https://vuejs.school/community/blog/render-functions-and-jsx","breadcrumb":{"@id":"https://vuejs.school/community/blog/render-functions-and-jsx#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/render-functions-and-jsx"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/render-functions-and-jsx#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-04-21","description":"Learn what h() returns, build a heading with a dynamic level, handle props and slots in render functions, set up JSX with Vite, and know when a template wins.","headline":"Render functions and JSX: when templates are not enough","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaA%2Cdescription_~TGVhcm4gd2hhdCBoKCkgcmV0dXJucywgYnVpbGQgYSBoZWFkaW5nIHdpdGggYSBkeW5hbWljIGxldmVsLCBoYW5kbGUgcHJvcHMgYW5kIHNsb3RzIGluIHJlbmRlciBmdW5jdGlvbnMsIHNldCB1cCBKU1ggd2l0aCBWaXRlLCBhbmQga25vdyB3aGVuIGEgdGVtcGxhdGUgd2lucy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZW5kZXItZnVuY3Rpb25zLWFuZC1qc3gi%2Cs_UEybevVKooqut1oW.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/render-functions-and-jsx#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/render-functions-and-jsx#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/render-functions-and-jsx#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Render functions and JSX: when templates are not enough","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaA%2Cdescription_~TGVhcm4gd2hhdCBoKCkgcmV0dXJucywgYnVpbGQgYSBoZWFkaW5nIHdpdGggYSBkeW5hbWljIGxldmVsLCBoYW5kbGUgcHJvcHMgYW5kIHNsb3RzIGluIHJlbmRlciBmdW5jdGlvbnMsIHNldCB1cCBKU1ggd2l0aCBWaXRlLCBhbmQga25vdyB3aGVuIGEgdGVtcGxhdGUgd2lucy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZW5kZXItZnVuY3Rpb25zLWFuZC1qc3gi%2Cs_UEybevVKooqut1oW.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UmVuZGVyIGZ1bmN0aW9ucyBhbmQgSlNYOiB3aGVuIHRlbXBsYXRlcyBhcmUgbm90IGVub3VnaA%2Cdescription_~TGVhcm4gd2hhdCBoKCkgcmV0dXJucywgYnVpbGQgYSBoZWFkaW5nIHdpdGggYSBkeW5hbWljIGxldmVsLCBoYW5kbGUgcHJvcHMgYW5kIHNsb3RzIGluIHJlbmRlciBmdW5jdGlvbnMsIHNldCB1cCBKU1ggd2l0aCBWaXRlLCBhbmQga25vdyB3aGVuIGEgdGVtcGxhdGUgd2lucy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZW5kZXItZnVuY3Rpb25zLWFuZC1qc3gi%2Cs_UEybevVKooqut1oW.png"}]}
```