Let It Fall Through: Clean Prop Forwarding with Vue’s $attrs
🧠 What Is Attribute Fallthrough?
Attribute fallthrough is a Vue feature that allows undeclared props to automatically pass down to a component’s root element or forwarded child via v-bind="attrs"
.
This pattern is especially useful when:
- A component doesn’t care about a prop, but needs to pass it to a child.
- You want to keep middle-layer components focused on logic, not styling or configuration.
🧱 The Setup: 3-Layer Example with Differentiated Logic
Imagine a dashboard with various types of notes: Dev, UX, and QA. Each note type has its own component with different responsibilities (e.g., fetching different APIs), but all of them use the same internal <RichEditor>
component.
Instead of making every middle layer duplicate readonly
, maxHeight
, or highlightColors
, we’ll use $attrs
to forward those props cleanly.
ProjectDashboard.vue
(grandparent)
<template>
<div class="q-gutter-md">
<DevNote
:readonly="true"
:maxHeight="'300px'"
:highlightColors="['yellow', 'green']"
/>
<UXNote
:readonly="false"
:minHeight="'150px'"
:maxHeight="'250px'"
:highlightColors="['cyan', 'pink']"
/>
<QANote :readonly="false" :maxHeight="'200px'" />
</div>
</template>
<script setup>
import DevNote from "./DevNote.vue";
import UXNote from "./UXNote.vue";
import QANote from "./QANote.vue";
</script>
✅ Each component has its own config. The dashboard doesn't care that each note type is implemented differently — it just applies a unified interface.
DevNote.vue
This component may fetch developer-specific notes from one API and transform the text before showing it.
<template>
<RichEditor :inputText="devNote" v-bind="attrs" />
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useAttrs } from "vue";
import RichEditor from "./RichEditor.vue";
const devNote = ref("");
onMounted(async () => {
const response = await fetch("/api/notes/dev");
devNote.value = (await response.json()).note;
});
const attrs = useAttrs();
</script>
🎨 UXNote.vue
(with a sibling component)
This one pulls Figma feedback and also renders a visual component related to design tasks.
<template>
<div class="ux-note-wrapper q-gutter-md">
<UXSpecificChild :note="uxNote" />
<RichEditor :inputText="uxNote" v-bind="attrs" />
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useAttrs } from "vue";
import RichEditor from "./RichEditor.vue";
import UXSpecificChild from "./UXSpecificChild.vue";
const uxNote = ref("");
onMounted(async () => {
const response = await fetch("/api/notes/ux");
uxNote.value = (await response.json()).note;
});
const attrs = useAttrs();
</script>
✅
UXNote
has additional structure — it’s not just a wrapper — but still passes config to<RichEditor>
without knowing the details.
🧪 QANote.vue
And this one loads test run results and annotates bugs.
<template>
<RichEditor :inputText="qaNote" v-bind="attrs" />
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useAttrs } from "vue";
import RichEditor from "./RichEditor.vue";
const qaNote = ref("");
onMounted(async () => {
const response = await fetch("/api/notes/qa");
qaNote.value = (await response.json()).note;
});
const attrs = useAttrs();
</script>
RichEditor.vue
(Child)
This is the shared editor with support for config and style props.
<script setup>
defineProps({
inputText: String,
readonly: { type: Boolean, default: false },
minHeight: String,
maxHeight: String,
highlightColors: Array,
});
</script>
<template>
<q-editor
v-model="inputText"
:readonly="readonly"
:min-height="minHeight"
:max-height="maxHeight"
class="my-editor"
/>
</template>
✅ Clean Separation of Concerns
- Each note component (
DevNote
,UXNote
, etc.) is free to have its own children, layout, API calls, and internal logic. - These components don't need to repeat or even know about presentation props like
readonly
,highlightColors
, etc. - Only the child that actually uses the props (
<RichEditor>
) needs to define them.
The result is:
- Cleaner code
- Reduced boilerplate
- Stronger encapsulation
⚠️ When to Use (and When Not To)
✅ Great for:
- Style/config passthrough
- Many components using a shared child component
- Reducing prop noise across layers
🚫 Avoid if:
- A component needs to transform or validate the prop
- Implicit behavior would cause confusion
💡 Rule of thumb: if a component needs to consume a prop, declare it. If it just passes it along, let it fall through.
🧩 Recap
- Vue’s
$attrs
and Composition API’suseAttrs()
allow middle components to forward undeclared props cleanly. - You can share common config (like editor props) across many components without duplication.
- Each component focuses on what it cares about — whether that’s fetching data or rendering UI.
🛠️ Next Steps
- Try identifying components in your app that repeat prop declarations for styling/config.
- Use
useAttrs()
to simplify them. - Build more modular layers by separating logic from layout/config.
Let it fall through — Vue’s got you covered.