skip to content

In Vue 3, what happens to a class, style, id or @click listener a parent passes to a component that declares none of them?

level: juniorimportance: must knowfreq 62%

answer

  1. not declared, so not consumed
  2. single root element receives them
  3. class and style merge
  4. both click handlers run
  5. plain attributes: parent value wins

basics

~10 s

Undeclared attributes and v-on listeners are fallthrough attributes: Vue adds them to the component's single root element, merging class and style with the root's own values and running both listeners.

solid answer

~40 s

Anything a parent passes that the child does not declare in `props` or `emits` becomes a **fallthrough attribute**, collected in `$attrs`. If the component renders a single root element, Vue adds them to it automatically. `class` and `style` are **merged** with the root's own (`btn` plus `large` renders `btn large`), a `@click` listener is added alongside any `@click` the root already has so both run, and other plain attributes such as `id` or `type` are set on the root, with the parent's value winning over the root's own. If the root is another component, the attributes are forwarded to it minus whatever the middle component declared.

code

vue · 16 lines
vue
<!-- SaveButton.vue -->
<script setup lang="ts">
function onOwnClick() {
  console.log('SaveButton handled click')
}
</script>

<template>
  <button class="btn" type="button" @click="onOwnClick">Save</button>
</template>

<!-- Parent usage:
  <SaveButton class="large" id="save" type="submit" @click="track" />
  renders roughly as
  <button class="btn large" type="submit" id="save">Save</button>
  and a click runs onOwnClick and then track -->

go deeper

for a junior

Recall the rule: undeclared attributes and listeners land on the single root element, with class and style merged rather than replaced.

for a middle

Explain the merge table: class concatenates, style merges per property, listeners stack, plain attributes take the parent's value, and declared props or emits are consumed first.

for a senior

Show you treat the root element as part of a component's contract, and know when a wrapper should stop inheriting and place $attrs explicitly.

for a principal

Discuss a library policy for which components inherit attributes onto their root and how changing a root element is handled as a breaking change.

## What counts as a fallthrough attribute A Vue component has a declared public interface: the **props** it lists with `defineProps` and the **events** it lists with `defineEmits`. Everything else a parent writes on the component tag is a **fallthrough attribute**. Typical examples: - `class` and `style` added by a layout that positions the component - `id`, `aria-*`, `data-*` and `title` attributes added for tests or accessibility - native-looking listeners such as `@click` or `@focus` that the component never declared as events Vue collects all of them into the component's `$attrs` object (in `<script setup>`, `useAttrs()` returns the same object). Keys keep their original casing, so `data-test-id` is read as `$attrs['data-test-id']`, and a listener `@click` appears as a function under `$attrs.onClick`. ## Single root: automatic inheritance When the component's template renders **exactly one root element**, Vue applies `$attrs` to that root for you. This is on by default (`inheritAttrs` defaults to `true`). Given a child whose template is `<button class="btn">Save</button>` and a parent writing `<SaveButton class="large" id="save" />`, the DOM becomes `<button class="btn large" id="save">Save</button>`. The rules differ by kind of attribute: | Passed by parent | Root already has one | Result on the root | |---|---|---| | `class` | yes | both values, merged into one class list | | `style` | yes | merged; the parent's value wins for the same CSS property | | `@click` listener | yes | both listeners attached, both run | | plain attribute (`id`, `type`) | yes | the parent's value replaces the root's own | | any of the above | no | simply added | The plain-attribute row surprises people: if the child renders `<button type="button">` and a parent passes `type="submit"`, the button submits forms. Vue merges fallthrough attributes after the root's own props, and for keys other than `class`, `style` and listeners the later value wins. ## Root is another component If the root node is itself a component, for example a `<MyButton>` whose whole template is `<BaseButton />`, the fallthrough attributes are **forwarded** to `<BaseButton>`. Two details matter: 1. Whatever `<MyButton>` declared as props or events is consumed there and is not forwarded. 2. `<BaseButton>` may accept some forwarded attributes as its own props if it declares them; the rest fall through again to its root. ## What does not fall through automatically - **Declared props and events** never appear in `$attrs`; they belong to the component's interface. - **Multi-root, text or teleport roots**: Vue cannot pick an element, so nothing is applied automatically and a development build warns. - **Components with `inheritAttrs: false`**: the author has opted out and places `$attrs` manually. ## Reading fallthrough attributes in code The same object that Vue applies to the root is available to the component itself: - in a template, as `$attrs`, for example `{{ $attrs.id }}` or `v-bind="$attrs"` - in `<script setup>`, through `useAttrs()` imported from `vue` - in a plain `setup(props, ctx)` function, as `ctx.attrs` The object is readonly and, according to Vue's documentation, not reactive, so it is meant for passing attributes along rather than for driving component logic. If the component needs to make a decision from a value, that value should be a declared prop. ## A quick mental checklist When an attribute a parent passes does not show up where expected, walk the same four questions Vue walks: 1. Did the child declare it as a prop or an event? Then it was consumed and is not in `$attrs`. 2. Does the child render a single root element or component? If not, nothing is applied automatically. 3. Did the child set `inheritAttrs: false`? Then it only lands where the child binds `$attrs`. 4. Is the root the element you expected? A wrapper `<div>` root receives everything, including listeners and accessibility attributes meant for an inner control. Inspecting the rendered DOM in the browser's developer tools answers all four in seconds. ## Why the design is useful and where it bites Fallthrough makes thin wrappers pleasant: a `<SaveButton>` can be positioned, labelled for tests and given a click handler without declaring every native attribute a `<button>` supports. The cost is that the root element quietly becomes part of the public contract. Changing the root from `<button>` to a `<div>` wrapper moves every consumer's `class`, `aria-label` and `@click` onto the `<div>`, where they may do nothing useful. Treat the root element choice as an API decision in a shared component library, and switch to `inheritAttrs: false` plus an explicit `v-bind="$attrs"` when the meaningful element is not the root.

  • In Vue 3, how does a parent's listener that fell through appear inside $attrs?
    As a function under an `on`-prefixed camelCase key, so `@click` becomes `$attrs.onClick`. Plain attributes keep their original casing, so `aria-label` is read as `$attrs['aria-label']`, unlike props, which are normalised to camelCase.
  • If the root of a Vue component is another component, where do its fallthrough attributes go?
    They are forwarded to that inner component, minus whatever the outer component declared as props or emits. The inner component may accept some of them as its own props; the rest fall through again to its own root.

saying these in an interview costs you the question

  • The parent's class replaces the class the root element already has.
  • Undeclared attributes are dropped unless the child declares them as props.
  • A parent @click on a component only fires if the child emits click.
  • When the root already has an attribute, the parent's value is ignored.
  • Declared props also appear on the root element as HTML attributes.