In a Vue 3 `<script setup>` component, why does logic derived from useAttrs() go stale, and what should you use instead?
answer
- setup runs only once
- always latest, not reactive
- invalid watch source
- declare it as a prop
- onUpdated for side effects
basics
~20 suseAttrs() returns an object that always holds the latest fallthrough attributes but is not reactive, so values copied in setup freeze and watchers cannot observe it. Declare a prop, read attrs during render, or use onUpdated.
solid answer
~40 s`useAttrs()` gives `<script setup>` the same object as `$attrs`. Vue keeps it up to date, but documents it as **not reactive** for performance. `setup` runs once, so `const { title } = useAttrs()` or `const hasIcon = 'icon' in attrs` captures a snapshot, and `watch(useAttrs(), cb)` is rejected as an invalid watch source because the object is neither a ref nor reactive. If the component's logic depends on a value, **declare it as a prop**, which is reactive, typed and validated. Reading `$attrs` in the template is fine, because each render sees the current object; for side effects on every change, the docs suggest `onUpdated`. The object is also readonly.
code
vue · 18 lines<script setup lang="ts">
import { onUpdated, useAttrs } from 'vue'
const attrs = useAttrs()
// Stale: runs once when setup runs
const initialTitle = attrs.title
// Documented escape hatch for side effects with the latest attrs
onUpdated(() => {
console.log('current title', attrs.title, 'initial', initialTitle)
})
</script>
<template>
<!-- Always current: read during render -->
<span :title="String($attrs.title ?? '')"><slot /></span>
</template>go deeper
Recall that useAttrs() exists for script setup and that values you need to react to should be declared as props.
Explain current-but-not-reactive, why setup-time copies freeze, and why watch rejects the attrs object as a source.
Spot the snapshot bug in review, move decision-driving values into props, and keep attrs strictly for forwarding.
Treat inspecting fallthrough attributes as an interface smell in a component library and push that data into declared, typed props.
## What useAttrs() returns `useAttrs()` is the `<script setup>` way to reach the **setup context's `attrs`**, the object holding a component's fallthrough attributes (everything passed but not declared in `props` or `emits`). Outside `<script setup>` the same object is `ctx.attrs` in `setup(props, ctx)`, and in templates it is `$attrs`. Three properties define it: - It **always reflects the latest** attributes; Vue updates its contents in place when the parent re-renders the child with new attributes. - It is **not a reactive object**. Vue's documentation says so explicitly and gives performance as the reason. - It is **readonly**: assigning a key logs `setupContext.attrs is readonly.` in development. Keys keep their original casing (`attrs['data-id']`), and listeners appear as `onClick`-style functions. ## How it goes stale The usual bug is treating attrs like reactive state: 1. **Snapshotting in setup.** `setup` runs **once** per component instance. `const { title } = useAttrs()` copies the value that was present at creation; when the parent later changes `title`, the local constant still holds the old string. 2. **Watching it.** `watch(useAttrs(), cb)` fails because a watch source must be a getter, a ref, a reactive object or an array of those. Vue logs `Invalid watch source` in development and the callback never runs. 3. **Deriving through toRefs or computed.** `toRefs(useAttrs())` warns that it expects a reactive object. A `computed` that reads attrs relies on behaviour Vue does not document, so it is not something to build on. ## What works instead | Need | Use | Why it works | |---|---|---| | Component logic depends on the value | declare it with `defineProps` | props are reactive, validated and typed | | Show or forward the value in markup | read `$attrs` in the template | each render reads the current object | | Run a side effect with the latest attrs | `onUpdated` | runs after each re-render, when attrs are current | | Split attrs between elements | a function called from the template | executes on every render, not once in setup | The first row is the real answer in most interviews. If the component makes decisions based on a value, that value is part of its **interface** and belongs in `defineProps`. Fallthrough attributes are meant to be passed along to an element, not inspected. ## Why the template path is safe When a parent passes a changed attribute, Vue sees the child vnode's props differ and re-renders the child. The render function reads `$attrs` fresh each time, so `v-bind="$attrs"` and `{{ $attrs.title }}` are always current. The staleness problem is specific to values captured once in `setup`. ## Where attrs fits in a component's design Fallthrough attributes and props answer different questions: - **props** are values the component **uses**: they feed computeds, conditions and emitted events, and the component owns their meaning - **attrs** are values the component **passes along**: native attributes and listeners that belong on an element it renders The non-reactive contract follows from that split. Vue has no reason to pay for dependency tracking on values that are only forwarded, and a component that reads an attribute to decide behaviour has quietly created an undeclared prop, one with no type, no default and no validator. The staleness bug is often the first visible symptom of that design drift, and moving the value into `defineProps` fixes both the bug and the interface at once. ## Interview framing - Name the two facts separately: attrs is **current** but not **reactive**. - Tie the snapshot bug to `setup` running once, which is Vue's model and not a quirk of attrs. - Offer the prop as the default fix and `onUpdated` as the documented escape hatch, with the note that it runs after every update of the component, not only when attrs change.
- Why is declaring a prop better than watching attrs when a Vue component depends on a value?A prop is reactive, so watchers and computeds track it; it can carry a type and a validator; and it documents the dependency in the component's interface. It also stops the value falling through to the root element as an HTML attribute.
- Is reading $attrs in a Vue template affected by the same staleness?No. The template compiles to a render function that reads `$attrs` every time it runs, and a changed attribute makes the child re-render. Only values copied once during `setup` go stale.
useAttrs() is like a pinned notice board someone keeps rewriting overnight: reading it now shows the latest notice, but it rings no bell when a notice changes, and a photo you took on day one stays out of date.
saying these in an interview costs you the question
- useAttrs() returns a reactive object, so watch(useAttrs(), cb) fires on changes.
- Destructuring useAttrs() at the top of script setup keeps the values in sync.
- Reading $attrs in the template shows the values from the first render.
- Keys in useAttrs() are camelCased like props.
- You can assign to useAttrs() properties to change what the root renders.