Functional Components

A component written as a plain function: props in, vnodes out, no instance, no lifecycle hooks, and where that pays.

Syntaxconst 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

Example · vue
// 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 IconButton

When 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.

Example · ts
// 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 StackLayout

What 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.

Example · ts
// 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 RawRow

When 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.

Example · vue
<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

  • Be the first to comment on this lesson.