skip to content

In Vue 3, how would you write a heading component that renders <h1> to <h6> from a level prop using a render function?

level: middleimportance: should knowfreq 30%

answer

  1. the tag is just a string
  2. template literal for the tag
  3. call the slot inside render
  4. attrs still fall through

basics

~20 s

Return a render function from setup() that calls h(h${props.level}, slots.default?.()). The tag is a plain string, so it follows the prop on every render, and calling the slot inside the render function keeps the heading text current.

solid answer

~40 s

Declare a `level` prop, optionally with a `validator` for 1 to 6, and return `() => h(`h${props.level}`, slots.default?.())` from `setup(props, { slots })`. The tag is just a string argument to `h()`, read on every render, so changing `level` swaps the element. Call the slot inside the render function so each render gets the current slot content; `?.()` covers a missing slot. Undeclared attributes such as `class` or `aria-*` still fall through to the single root, as with a template. The file is a `.ts` module or a plain `<script>` block, because `<script setup>` cannot return a render function. A template can do the same with `<component :is>`, but the render function stays compact once more logic joins in.

code

ts · 18 lines
ts
import { defineComponent, h, type PropType } from 'vue'

type Level = 1 | 2 | 3 | 4 | 5 | 6

export default defineComponent({
  name: 'AppHeading',
  props: {
    level: {
      type: Number as PropType<Level>,
      default: 2,
      validator: (v: number) => Number.isInteger(v) && v >= 1 && v <= 6
    }
  },
  setup(props, { slots }) {
    // tag and slot content are both read on every render
    return () => h(`h${props.level}`, slots.default?.())
  }
})

go deeper

for a junior

Recall that h() takes the tag as a string, so a template literal like h${level} can pick the element.

for a middle

Explain why both the prop read and the slot call must happen inside the returned render function, and that fallthrough attrs still reach the root.

for a senior

Choose between a render function and a dynamic component for design-system primitives, and keep the semantic level separate from visual size for accessibility.

for a principal

Decide which design-system primitives are worth writing as render functions, weighing their flexibility against template readability for the team.

## The problem A design system needs one heading component whose visual style is shared but whose **semantic level** varies: a card title might be an `<h3>` on one page and an `<h2>` on another, and screen-reader users navigate by those levels. The component takes a `level` prop from 1 to 6 and must render the matching element, with the heading text supplied through its default slot. In a template, the tag name is fixed by the markup you write, so you either write six `v-if` branches or reach for a dynamic `<component :is>`. With a render function the tag is just a string argument to `h()`, which makes this a classic render-function exercise in Vue interviews. ## The render-function version ```ts import { defineComponent, h, type PropType } from 'vue' type Level = 1 | 2 | 3 | 4 | 5 | 6 export default defineComponent({ name: 'AppHeading', props: { level: { type: Number as PropType<Level>, default: 2, validator: (v: number) => Number.isInteger(v) && v >= 1 && v <= 6 } }, setup(props, { slots }) { return () => h(`h${props.level}`, slots.default?.()) } }) ``` Used as `<AppHeading :level="3" class="card-title">Pricing</AppHeading>`, it renders `<h3 class="card-title">Pricing</h3>`. ## Why each piece is there 1. **The tag is computed inside the render function.** `props.level` is read on every render, so when the parent changes `level` from 2 to 3 the component re-renders and produces an `<h3>`. 2. **The slot is called inside the render function.** In `setup()` the context's `slots` object holds slot functions. Calling `slots.default()` during each render returns fresh vnodes for the current slot content; calling it once in the `setup()` body would capture the first content and never update. 3. **`?.()` handles a missing slot.** If the parent passes no children, `slots.default` is `undefined` and the heading renders empty instead of throwing. 4. **Fallthrough attributes still work.** Attributes the component does not declare as props, such as `class`, `id` or `aria-*`, fall through to the single root node exactly as they would with a template, so nothing has to be forwarded by hand. 5. **The validator documents intent.** Vue's prop `validator` only warns in development, so it catches a `level="7"` during testing without adding production cost. ## The template alternative | Approach | How the tag varies | Trade-off | |---|---|---| | Render function with `h()` | the tag is a template-literal string | compact; logic and markup live in one function | | `<component :is="`h${level}`">` | dynamic component with a tag string | stays in template syntax | | six `v-if` branches | one branch per tag | verbose and easy to let drift | The dynamic-component template works too. The render function is worth choosing when the output shape is driven by logic, for example when the heading also generates an anchor link from its text or wraps itself in a section only at certain levels. ## Details worth mentioning in the interview - **Semantic versus visual level.** Pages often need an `<h2>` that looks like a small title. A second prop such as `size` can drive a class (`h(`h${props.level}`, { class: `heading--${props.size ?? props.level}` }, ...)`) so the document outline and the styling vary independently. - **Out-of-range values.** The prop validator only warns in development, so decide explicitly what production does with a bad `level` (for example clamp it to 1-6) instead of letting an `h7` element reach the DOM. - **Empty headings.** An empty heading still sits in the document outline; when the slot is missing, returning `null` from the render function (Vue renders a comment placeholder) is a reasonable choice. - **Naming.** An explicit `name` option lets devtools and warning traces identify the component, and lets it refer to itself by name if it ever renders recursively. - **Testing.** Mount it with different `level` values and assert the tag name and that the slot text and a fallthrough `class` are present. ## Where this code lives `<script setup>` cannot return a render function, so a component like this is a `.ts` file with `defineComponent`, or an SFC with a plain `<script>` block. Vue 3.3+ also accepts `defineComponent((props, { slots }) => () => ..., { props: {...} })`, the function signature.

  • How would you let the heading look smaller than its semantic level suggests?
    Add a second prop, for example `size`, and derive a class from it inside the render function: `h(`h${props.level}`, { class: `heading--${props.size ?? props.level}` }, slots.default?.())`. The tag keeps the document outline correct for assistive technology, while the class controls appearance, so the two no longer have to move together.
  • How would you add an anchor link built from the heading text?
    Inside the render function, call `slots.default?.()` once, derive a slug from the text children (or accept an `id` prop), and return `h(`h${props.level}`, { id: slug }, [h('a', { href: `#${slug}` }, '#'), ...children])`. This kind of logic-driven output is where a render function reads better than template branches.

saying these in an interview costs you the question

  • Calls slots.default() once in the setup body and reuses the result
  • Writes six v-if branches in the render function instead of one string
  • Believes attributes do not fall through from render-function components
  • Tries to return the render function from a <script setup> block
  • Treats the prop validator as a production runtime guard