.agents/skills/vue-best-practices/references/component-fallthrough-attrs.md
Impact: MEDIUM - Fallthrough attributes are straightforward once you follow Vue's conventions: hyphenated names use bracket notation, listener keys are camelCase onX, and useAttrs() is current-but-not-reactive.
attrs['data-testid'])onX keys (for example attrs.onClick)watch() values returned from useAttrs(); those watchers do not trigger on attr changesonUpdated() for attr-driven side effectsHyphenated attribute names preserve their original casing in JavaScript, so dot notation does not work for keys that include -.
BAD:
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs.data - testid) // Syntax error
console.log(attrs.dataTestid) // undefined for data-testid
console.log(attrs['on-click']) // undefined
console.log(attrs['@click']) // undefined
</script>
GOOD:
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs['data-testid'])
console.log(attrs['aria-label'])
console.log(attrs['foo-bar'])
console.log(attrs.onClick)
console.log(attrs.onCustomEvent)
console.log(attrs.onMouseEnter)
</script>
| Parent Usage | Access in attrs |
|---|---|
class="foo" | attrs.class |
data-id="123" | attrs['data-id'] |
aria-label="..." | attrs['aria-label'] |
foo-bar="baz" | attrs['foo-bar'] |
@click="fn" | attrs.onClick |
@custom-event="fn" | attrs.onCustomEvent |
@update:modelValue="fn" | attrs['onUpdate:modelValue'] |
useAttrs() Is Not ReactiveuseAttrs() always reflects the latest values, but it is intentionally not reactive for watcher tracking.
BAD:
<script setup>
import { useAttrs, watch, watchEffect } from 'vue'
const attrs = useAttrs()
watch(
() => attrs.someAttr,
(newValue) => {
console.log('Changed:', newValue) // Never runs on attr changes
}
)
watchEffect(() => {
console.log(attrs.class) // Runs on setup, not on attr updates
})
</script>
GOOD:
<script setup>
import { onUpdated, useAttrs } from 'vue'
const attrs = useAttrs()
onUpdated(() => {
console.log('Latest attrs:', attrs)
})
</script>
GOOD:
<script setup>
import { watch } from 'vue'
const props = defineProps({
someAttr: String
})
watch(
() => props.someAttr,
(newValue) => {
console.log('Changed:', newValue)
}
)
</script>
<script setup>
import { computed, useAttrs } from 'vue'
const attrs = useAttrs()
const hasTestId = computed(() => 'data-testid' in attrs)
const ariaLabel = computed(() => attrs['aria-label'] ?? 'Default label')
</script>
<script setup>
import { useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
function handleClick(event) {
console.log('Internal handling first')
attrs.onClick?.(event)
}
</script>
<template>
<button @click="handleClick">
<slot />
</button>
</template>
useAttrs() is typed as Record<string, unknown>, so cast individual keys when needed.
<script setup lang="ts">
import { useAttrs } from 'vue'
const attrs = useAttrs()
const testId = attrs['data-testid'] as string | undefined
const onClick = attrs.onClick as ((event: MouseEvent) => void) | undefined
</script>