JSX in Vue
Install the Vue JSX plugin, learn Vue's own JSX dialect, and see when tag syntax reads better than nested h() calls.
return () => <span class={cls} onClick={handler}>{text}</span>JSX is a syntax extension for JavaScript that lets you write tag-shaped expressions in code. It is not part of the language, so a build step rewrites it. With Vue's plugin, <span class="badge">{text}</span> compiles to an h() call. Nothing else changes: the component still returns a render function, and reactivity still works by tracking which properties you read.
Setting it up
Install @vitejs/plugin-vue-jsx and add vueJsx() to the plugins array in vite.config.ts. For TypeScript, set "jsx": "preserve" and "jsxImportSource": "vue" in the tsconfig. npm create vue@latest asks "Add JSX Support?" and wires up both for you. Files then end in .jsx or .tsx.
Vue's JSX is its own dialect
If you have met JSX elsewhere, the differences are small but real. Props keep their HTML names: class and for, not className and htmlFor. Listeners are props named onClick. Slots are passed as an object through a v-slots prop, because a component's children are slot functions. v-model and v-show are supported by the plugin; v-if and v-for have no JSX form, so use a conditional expression and .map().
When to reach for it
JSX reads better than h() once the markup nests more than a level or two. It is a different notation for the same thing, not a different rendering model.
Example
// src/components/StatusBadge.tsx
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
type Status = 'ok' | 'warn' | 'down'
export default defineComponent(
(props: { status: Status; label: string }) => {
const text = computed(() => props.label.toUpperCase())
return () => (
<span class={['badge', `badge-${props.status}`]} title={props.label}>
<i class="badge-dot" aria-hidden="true" />
{text.value}
</span>
)
},
{
props: {
status: { type: String as PropType<Status>, required: true },
label: { type: String, required: true }
}
}
)When to use it
- Write a deeply nested wrapper component without four levels of h() calls and closing brackets.
- Port a component whose structure is decided in JavaScript into something a reviewer can scan.
- Give a design-system primitive a typed, generic props signature that a template cannot express.
More examples
Turning the plugin on
The JSX transform is a separate plugin from plugin-vue, and both are needed in a project that has .vue files and .tsx files. Add it when you declined JSX support at scaffold time and now want a .tsx component.
// 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()]
})The TypeScript side of the switch
Without these two options TypeScript either rejects the tag syntax or type-checks it against the wrong library's element types. "preserve" leaves the JSX for the Vite plugin to transform, and jsxImportSource points the element types at Vue.
// tsconfig.app.json
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "vue"
}
}Slots and v-model in JSX
Slots are built as an object of functions and handed over with v-slots. v-model is written against the ref's .value, because the plugin compiles it into an assignment plus an onUpdate:modelValue listener. The panel shows a text field and a live character count under it.
// src/components/SearchPanel.tsx
import { defineComponent, ref } from 'vue'
import TextField from './TextField.vue'
import Panel from './Panel.vue'
export default defineComponent(() => {
const term = ref('')
const slots = {
default: () => <TextField v-model={term.value} label="Search" />,
footer: () => <p class="hint">{term.value.length} characters</p>
}
return () => <Panel v-slots={slots} title="Find a customer" />
})
Discussion