Functional Components
A component written as a plain function: props in, vnodes out, no instance, no lifecycle hooks, and where that pays.
const C: FunctionalComponent<Props, Events> = (props, { attrs, slots, emit }) => h(...)A functional component is a component written as a plain function. Props go in, vnodes come out. Vue renders it without creating a component instance, so there is no this, no state of its own and no lifecycle hooks.
How it works
The signature is the same as setup(): (props, { attrs, slots, emit }). The function body is the render function, so it returns vnodes directly rather than returning another function. Options are attached to the function as properties: MyComponent.props, MyComponent.emits, MyComponent.inheritAttrs. It registers and is used like any other component, and passing a function as the first argument to h() is treated as a functional component.
In TypeScript, annotate it with FunctionalComponent<Props, Events>, or write a named function and type its second parameter SetupContext<Events>. The runtime props declaration is still needed alongside the types.
Where it is genuinely lighter
No instance is created and no lifecycle bookkeeping runs, so the saving is per rendered copy and small. It shows up in a list with thousands of rows, not in a page with twenty components. Good candidates are presentational leaves: a chip, a cell, an icon, a wrapper that picks between two children.
What trips people up
Declaring props is not really optional. Without it, the props object holds every attribute under its original name, and attribute fallthrough narrows to class, style and onXxx listeners. The moment you need a watcher, a cleanup or a template ref, make it an ordinary component.
Example
// src/components/IconButton.tsx
import type { FunctionalComponent } from 'vue'
type IconButtonProps = { label: string; icon: string; disabled?: boolean }
type IconButtonEvents = { press(): void }
const IconButton: FunctionalComponent<IconButtonProps, IconButtonEvents> = (
props,
{ emit }
) => (
<button
type="button"
class="icon-button"
disabled={props.disabled}
onClick={() => emit('press')}
>
<svg class="icon-button-glyph" aria-hidden="true">
<use href={`#${props.icon}`} />
</svg>
<span>{props.label}</span>
</button>
)
IconButton.props = {
label: { type: String, required: true },
icon: { type: String, required: true },
disabled: { type: Boolean, default: false }
}
IconButton.emits = {
press: () => true
}
export default IconButtonWhen to use it
- A table cell or list row rendered thousands of times, where per-instance cost is measurable.
- A tiny presentational wrapper that only picks a tag or a class from its props.
- A pass-through component in a library, where you want attribute fallthrough but no instance.
More examples
The same idea with h() and a slot
A functional component does not need JSX. This one takes its children from the default slot and spaces them with a grid gap, which is the sort of layout primitive you end up using on most screens. slots.default is optional, hence the ?. call.
// src/components/StackLayout.ts
import { h } from 'vue'
import type { FunctionalComponent } from 'vue'
const StackLayout: FunctionalComponent<{ gap: number }> = (props, { slots }) =>
h(
'div',
{ class: 'stack', style: { display: 'grid', rowGap: `${props.gap}px` } },
slots.default?.()
)
StackLayout.props = {
gap: { type: Number, default: 8 }
}
export default StackLayoutWhat happens when props is left out
Rendered as <raw-row max-rows="3" />, this logs the two lines below: the attribute arrives as a prop under its HTML name, and no camelCase alias is created. That mismatch is the usual reason a functional component reads undefined for a prop that is plainly in the markup.
// src/components/RawRow.ts
import { h } from 'vue'
import type { FunctionalComponent } from 'vue'
const RawRow: FunctionalComponent = (props, { slots }) => {
console.log(Object.keys(props).includes('max-rows')) // Output: true
console.log(Object.keys(props).includes('maxRows')) // Output: false
return h('div', { class: 'row' }, slots.default?.())
}
export default RawRowWhen it has to become an ordinary component
Autofocus needs a mounted hook and a reference to the real element, and a functional component has neither. This is the same button as a single-file component, and the conversion is the right move as soon as a hook appears. useTemplateRef is Vue 3.5; before that you declared a ref whose name matched the ref attribute.
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
const props = defineProps<{ label: string; autofocus?: boolean }>()
const emit = defineEmits<{ press: [] }>()
const button = useTemplateRef<HTMLButtonElement>('button')
onMounted(() => {
if (props.autofocus) {
button.value?.focus()
}
})
</script>
<template>
<button ref="button" type="button" class="icon-button" @click="emit('press')">
{{ props.label }}
</button>
</template>
<style scoped>
.icon-button {
display: inline-flex;
gap: 0.5rem;
}
</style>
Discussion