Files
shiftcraft/.claude/skills/vue-best-practices/references/component-fallthrough-attrs.md
2026-04-17 23:26:01 +00:00

4.0 KiB

title, impact, impactDescription, type, tags
title impact impactDescription type tags
Component Fallthrough Attributes Best Practices MEDIUM Incorrect $attrs access and reactivity assumptions can cause undefined values and watchers that never run best-practice
vue3
attrs
fallthrough-attributes
composition-api
reactivity

Component Fallthrough Attributes Best Practices

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.

Task List

  • Access hyphenated attribute names with bracket notation (for example attrs['data-testid'])
  • Access event listeners with camelCase onX keys (for example attrs.onClick)
  • Do not watch() values returned from useAttrs(); those watchers do not trigger on attr changes
  • Use onUpdated() for attr-driven side effects
  • Promote frequently observed attrs to props when reactive observation is required

Access Attribute and Listener Keys Correctly

Hyphenated 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>

Naming Reference

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 Reactive

useAttrs() always reflects the latest values, but it is intentionally not reactive for watcher tracking.

BAD:

<script setup>
import { watch, watchEffect, useAttrs } 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>

Common Patterns

Check for optional attrs safely

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

Forward listeners after internal logic

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

TypeScript Notes

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>