Logik und Darstellung sauber trennen in Vue.js
Eine Komponente, die gleichzeitig Logik verwaltet und HTML rendert, lässt sich kaum wiederverwenden. Renderless Components und Headless Patterns in Vue.js lösen dieses Problem: Die Logik lebt in einem Composable oder einer renderless Komponente, die Darstellung übernimmt der Konsument vollständig per Scoped Slot oder Template.
Inhaltsverzeichnis
- 1. Was Renderless Components in Vue leisten
- 2. Das Renderless-Pattern: Komponente ohne eigenes Template
- 3. Headless UI: Von der Komponente zum Konzept
- 4. Composable vs. Renderless Component: Wann welches Muster?
- 5. Scoped Slots als Kommunikationskanal
- 6. provide/inject für tief verschachtelte Headless-Bäume
- 7. Headless in Design Systems: Stilfreiheit ohne Code-Duplikat
- 8. Renderless Components testen
- 9. Renderless vs. Headless vs. klassisches Component-Muster
- 10. Zusammenfassung
- 11. FAQ
1. Was Renderless Components in Vue leisten
Das zentrale Problem in wachsenden Vue-Anwendungen ist die Kopplung von Logik und Darstellung in derselben Komponente. Ein Autocomplete-Component enthält sowohl die Such-Logik, die Tastaturnavigation, das Öffnen und Schließen des Dropdown und gleichzeitig das spezifische HTML-Markup mit seinen Design-Klassen. Das macht die Komponente nicht wiederverwendbar: Wer dasselbe Autocomplete-Verhalten mit anderem Markup braucht — in einem anderen Design-System, in einer anderen Farbe, als Mobile-optimierte Variante — muss die gesamte Komponente duplizieren oder in ein undurchsichtiges Props-Gebirge transformieren.
Renderless Components in Vue lösen dieses Problem durch radikale Trennung: Eine renderless Komponente rendert kein eigenes HTML, sondern stellt Logik, State und Aktionen via Scoped Slot bereit. Der Konsument entscheidet vollständig über das Markup. Das ist kein akademisches Konzept, sondern der Ansatz hinter Headless-UI-Bibliotheken wie Radix UI, Headless UI oder Vue's eigenem VueUse. Die Logik lebt einmal, die Darstellungen sind beliebig viele.
2. Das Renderless-Pattern: Komponente ohne eigenes Template
Eine renderless Komponente in Vue hat ein Template, das ausschließlich aus einem <slot> besteht — oder bei funktionaler Implementierung nur eine render-Funktion, die den Default-Slot mit State-Daten aufruft. Die gesamte Logik lebt in der setup-Funktion. State, computed Properties und Event-Handler werden als Slot-Props nach außen exponiert, damit der Konsument sie im eigenen Template verwenden kann. Das macht die renderless Komponente zu einem reinen Verhaltens-Provider ohne Meinung über die Darstellung.
Das Pattern ist in Vue 3 mit der Composition API besonders elegant, weil setup ohnehin alle reaktiven Daten zurückgibt. Eine renderless Komponente ist konzeptuell fast identisch mit einem Composable — der Unterschied liegt im Kontext: Das Composable wird in der setup-Funktion der Konsumentenkomponente eingebunden, während die renderless Komponente im Template des Konsumenten als Tag auftaucht und ihren State via Slot-Props teilt. Beide Muster haben ihre Berechtigung, je nachdem ob Template-Composition oder Code-Composition bevorzugt wird.
<!-- RenderlessAutocomplete.vue — Pure logic, no markup -->
<template>
<!-- All state and actions exposed via scoped slot -->
<slot
:query="query"
:results="results"
:isOpen="isOpen"
:isLoading="isLoading"
:activeIndex="activeIndex"
:onInput="handleInput"
:onKeydown="handleKeydown"
:selectItem="selectItem"
:close="close"
/>
</template>
<script setup>
// Renderless component: only logic, no visual opinion
import { ref, watch } from 'vue'
const props = defineProps({
modelValue: String,
fetchFn: { type: Function, required: true },
debounce: { type: Number, default: 300 },
})
const emit = defineEmits(['update:modelValue', 'select'])
const query = ref(props.modelValue ?? '')
const results = ref([])
const isOpen = ref(false)
const isLoading = ref(false)
const activeIndex = ref(-1)
let debounceTimer = null
const fetchResults = async (val) => {
if (!val.trim()) { results.value = []; isOpen.value = false; return }
isLoading.value = true
try {
results.value = await props.fetchFn(val)
isOpen.value = results.value.length > 0
} finally { isLoading.value = false }
}
const handleInput = (e) => {
query.value = e.target.value
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => fetchResults(query.value), props.debounce)
}
const handleKeydown = (e) => {
if (e.key === 'ArrowDown') activeIndex.value = Math.min(activeIndex.value + 1, results.value.length - 1)
if (e.key === 'ArrowUp') activeIndex.value = Math.max(activeIndex.value - 1, 0)
if (e.key === 'Enter' && activeIndex.value >= 0) selectItem(results.value[activeIndex.value])
if (e.key === 'Escape') close()
}
const selectItem = (item) => { emit('select', item); emit('update:modelValue', item.label); close() }
const close = () => { isOpen.value = false; activeIndex.value = -1 }
</script>
3. Headless UI: Von der Komponente zum Konzept
Headless Patterns in Vue gehen einen Schritt weiter als einzelne renderless Komponenten: Sie beschreiben ein ganzes Konzept von Komponenten, die ausschließlich Verhalten und Zugänglichkeit liefern, niemals Styling. Die Idee entstammt der Beobachtung, dass Barrierefreiheit — korrekte ARIA-Attribute, Tastaturnavigation, Fokus-Management — für jede Art von Dropdown, Modal, Accordion oder Combobox identisch ist, egal wie sie aussieht. Diese Logik einmal korrekt zu implementieren und dann für beliebige visuelle Varianten wiederzuverwenden ist das Ziel des Headless-Ansatzes.
In Vue bedeutet das: Eine Headless-Disclosure-Komponente weiß, wann ein Bereich geöffnet oder geschlossen ist, setzt korrekte aria-expanded- und aria-controls-Attribute, handhabt Keyboard-Events und bietet einen Slot für den Trigger und einen für den Content. Was der Trigger visuell ist — ein Button, ein Link, ein Icon — entscheidet ausschließlich der Konsument. Die Headless-Komponente garantiert nur, dass es korrekt und zugänglich funktioniert.
4. Composable vs. Renderless Component: Wann welches Muster?
Die Frage, wann man ein Composable und wann eine renderless Komponente in Vue verwendet, ist eine Frage des Verwendungskontexts. Composables sind besser geeignet, wenn die Logik in bestehenden Komponenten eingebunden werden soll, ohne das Template zu verändern. Ein useAutocomplete-Composable kann in jede bestehende Komponente importiert werden, die bereits das richtige HTML-Markup hat. Die renderless Komponente ist besser geeignet, wenn die gesamte Komponentenhierarchie flexibel sein soll und der Konsument sein eigenes Template von Grund auf aufbaut.
Ein praktisches Kriterium: Wenn die Logik mehrere zusammengehörige Elemente steuert — zum Beispiel ein Trigger-Button und ein Content-Panel wie bei einem Accordion — ist die renderless Komponente eleganter, weil sie via Slot-Props beide Teile koordinieren kann. Wenn die Logik nur einen reaktiven State und ein paar Methoden umfasst — zum Beispiel eine Toggle-Funktion — reicht ein Composable vollständig aus. In modernen Vue-3-Projekten ist die Tendenz klar: Composables für reine Logik, renderless Komponenten wenn Template-Struktur koordiniert werden muss.
// composables/useDisclosure.js — Pure logic for open/close state
import { ref, computed } from 'vue'
export function useDisclosure(initialState = false) {
const isOpen = ref(initialState)
// Aria attributes computed — consumer just spreads them onto elements
const triggerAttrs = computed(() => ({
'aria-expanded': isOpen.value,
'aria-controls': 'disclosure-panel',
}))
const panelAttrs = computed(() => ({
id: 'disclosure-panel',
'aria-hidden': !isOpen.value,
}))
const open = () => { isOpen.value = true }
const close = () => { isOpen.value = false }
const toggle = () => { isOpen.value = !isOpen.value }
return { isOpen, open, close, toggle, triggerAttrs, panelAttrs }
}
// Usage in a component — full control over markup
// const { isOpen, toggle, triggerAttrs, panelAttrs } = useDisclosure()
// <button v-bind="triggerAttrs" @click="toggle">Toggle</button>
// <div v-bind="panelAttrs" v-show="isOpen"><slot /></div>
5. Scoped Slots als Kommunikationskanal
Scoped Slots sind das Rückgrat des Renderless-Patterns in Vue. Sie ermöglichen es einer Kind-Komponente, Daten in den Slot-Inhalt des Elternteils zu injizieren — umgekehrt zur normalen Datenflusskette in Vue. Die renderless Komponente bindet reaktive State-Werte und Methoden an den Slot, der Konsument empfängt sie als Template-Variablen und kann sie frei verwenden. Das ist mächtiger als Props, weil es volle Kontrolle über das Markup gibt, ohne eine feste Komponenten-API zu erzwingen.
Das Muster ist aber nicht unbegrenzt skalierbar. Wenn eine renderless Komponente mehr als zehn Slot-Props exponiert, wird die API unübersichtlich. Hier hilft eine Gruppierung: Statt zehn einzelner Props einen State-Objekt-Slot und einen Actions-Objekt-Slot exponieren. v-slot="{ state, actions }" ist lesbarer als v-slot="{ isOpen, query, activeIndex, results, onInput, onKeydown, selectItem, close, reset, isLoading }". Dieses Prinzip gilt auch für die TypeScript-Typisierung: Slot-Props sollten als Interface definiert sein, das der Konsument importieren und nutzen kann.
6. provide/inject für tief verschachtelte Headless-Bäume
Scoped Slots funktionieren gut, wenn die renderless Komponente direkt die Slot-Konsumenten steuert. Bei tief verschachtelten Komponentenbäumen — zum Beispiel einer Headless-Tabs-Komponente, die Tabs und Panels in beliebig tiefen Kind-Hierarchien verwaltet — werden Scoped Slots unpraktisch. Hier greift das provide/inject-Pattern in Vue, das für genau diesen Anwendungsfall konzipiert wurde: Eine übergeordnete Headless-Komponente stellt via provide einen geteilten State bereit, jede Kind-Komponente in der Hierarchie greift via inject darauf zu.
Das provide/inject-Muster ist typsicher mit einem InjectionKey-Symbol aus TypeScript: Die übergeordnete Headless-Komponente exportiert den Key, Kind-Komponenten importieren ihn und erhalten vollständige TypeScript-Autovervollständigung auf dem injizierten State. Zusammen mit readonly-Wrapping des bereitgestellten States — Kind-Komponenten können den State lesen, aber Mutationen nur via exponierte Methoden auslösen — ergibt sich ein sauberes unidirektionales Datenflussmuster auch in komplexen Headless-Hierarchien.
7. Headless in Design Systems: Stilfreiheit ohne Code-Duplikat
Der größte praktische Vorteil von Headless Patterns in Vue zeigt sich in Design Systems mit mehreren visuellen Varianten. Statt für jede Variante — Dark Mode, Mobile-Layout, kompaktes Dashboard-Layout — eine eigene Komponente mit duplizierter Logik zu pflegen, gibt es eine einzige Headless-Basiskomponente und beliebig viele Präsentationskomponenten darüber. Das bedeutet: Wenn ein Bug in der Tastaturnavigation eines Accordions gefunden wird, wird er einmal in der Headless-Basiskomponente behoben und alle Varianten profitieren sofort.
Bibliotheken wie Radix Vue oder Headless UI for Vue implementieren dieses Prinzip konsequent. In eigenen Design Systems folgt man demselben Muster: Headless-Komponenten in einem eigenen Paket oder Verzeichnis, Präsentationskomponenten als separate Wrapper, die ausschließlich Klassen und Markup definieren. Die Headless-Schicht ist stabil und getestet, die Präsentationsschicht ist leichtgewichtig und leicht austauschbar. Das ist das Fundament für wartbare, skalierbare Vue-Komponentenbibliotheken.
<!-- HeadlessTabs.vue — Logic and ARIA, no visual opinion -->
<template>
<!-- Provide shared state to all child Tab and Panel components -->
<slot :activeTab="activeTab" :setTab="setTab" />
</template>
<script setup>
import { ref, provide } from 'vue'
import { TABS_INJECTION_KEY } from './tabs-injection-key'
const props = defineProps({ defaultTab: { type: String, required: true } })
const activeTab = ref(props.defaultTab)
const setTab = (id) => { activeTab.value = id }
// Provide state to all descendant Tab/Panel components
provide(TABS_INJECTION_KEY, { activeTab, setTab })
</script>
<!-- DesignSystemTabs.vue — Visual wrapper using headless core -->
<template>
<HeadlessTabs :defaultTab="defaultTab">
<template #default="{ activeTab, setTab }">
<div class="border-b border-slate-200 flex gap-0">
<button
v-for="tab in tabs"
:key="tab.id"
:aria-selected="activeTab === tab.id"
:class="['px-4 py-2 text-sm font-medium border-b-2 transition-colors',
activeTab === tab.id
? 'border-green-600 text-green-700'
: 'border-transparent text-slate-500 hover:text-slate-700']"
@click="setTab(tab.id)"
>{ { tab.label } }</button>
</div>
<div class="mt-4">
<slot :activeTab="activeTab" />
</div>
</template>
</HeadlessTabs>
</template>
8. Renderless Components testen
Einer der größten Vorteile von Renderless Components in Vue ist die Testbarkeit. Da eine renderless Komponente kein Markup rendert, können Tests vollständig auf die Logik fokussieren, ohne sich um CSS-Klassen oder visuelle Details zu kümmern. Mit Vue Test Utils mounted man die renderless Komponente mit einem Test-Slot, der die Slot-Props in lokale Variablen aufnimmt. Dann werden Aktionen simuliert und der State überprüft — vollständig entkoppelt vom visuellen Rendering.
Der zweite Vorteil: Wenn die gesamte Logik in einem Composable liegt, kann das Composable direkt mit withSetup oder dem VueUse-Testpattern getestet werden, ohne eine Komponente zu mounten. Das macht Tests schneller und weniger fragil. Renderless Components und Headless Patterns sind deshalb nicht nur eine Architekturentscheidung für Wartbarkeit, sondern direkt mit der Qualität der Testabdeckung verknüpft — mehr Logik in Composables bedeutet einfachere, stabilere Tests.
9. Renderless vs. Headless vs. klassisches Component-Muster
| Kriterium | Klassisch | Renderless | Headless |
|---|---|---|---|
| Markup-Kontrolle | Nur via Props | Vollständig beim Konsumenten | Vollständig beim Konsumenten |
| Wiederverwendbarkeit | Niedrig | Hoch | Sehr hoch |
| Testbarkeit | Mittel | Hoch | Sehr hoch |
| Einstiegshürde | Niedrig | Mittel | Höher |
| Styling-Flexibilität | Begrenzt | Vollständig | Vollständig |
| A11y-Garantie | Manuell | Manuell | Eingebaut |
Klassische Komponenten sind der richtige Ausgangspunkt für einfache, einmalig verwendete UI-Bausteine. Renderless Components lohnen sich, sobald dieselbe Logik in mehreren visuellen Varianten benötigt wird. Headless Patterns sind die Wahl für Komponentenbibliotheken und Design Systems, bei denen Zugänglichkeit und vollständige Styling-Kontrolle gleichzeitig gefragt sind. Die drei Muster schließen sich nicht aus — ein gut strukturiertes Vue-Projekt nutzt alle drei je nach Komplexität und Wiederverwendungsbedarf.
Mironsoft
Vue.js Komponentenarchitektur, Design Systems und Composable-Entwicklung
Vue-Komponentenbibliothek mit maximaler Wiederverwendbarkeit?
Wir entwickeln Renderless Components und Headless Patterns, die Logik und Darstellung sauber trennen — für wartbare Vue-Design-Systems mit vollständiger Styling-Kontrolle und A11y-Garantie.
Architektur-Review
Analyse bestehender Komponenten auf Logik-UI-Kopplung und Optimierungspotenzial
Headless-Entwicklung
Zugängliche Headless-Basiskomponenten für Accordion, Tabs, Dropdown, Combobox
Design-System-Integration
Präsentationsschicht als dünner Wrapper über Headless-Kern — stabil und austauschbar
10. Zusammenfassung
Renderless Components und Headless Patterns in Vue.js lösen das fundamentale Problem der Logik-UI-Kopplung. Eine renderless Komponente rendert kein eigenes Markup, stellt aber den vollständigen State und alle Aktionen via Scoped Slot bereit. Der Konsument entscheidet vollständig über das Aussehen. Headless Patterns erweitern diesen Ansatz auf ganze Komponentenhierarchien mit eingebauter Barrierefreiheit. Composables ergänzen das Bild: für reine Logik ohne Template-Koordination sind sie die leichtgewichtigste Variante.
Die Trennlinie zwischen den Mustern ist klar: Composable für isolierte Logik, renderless Komponente wenn Slot-Koordination nötig ist, Headless-Hierarchie wenn ganze Komponentenbäume koordiniert werden müssen. In Design Systems sind Headless Patterns der Schlüssel zu wartbaren Bibliotheken — ein Bug in der Tastaturnavigation wird einmal behoben und betrifft alle visuellen Varianten. Testbarkeit ist ein direkter Nebeneffekt: Logik in Composables und renderless Komponenten lässt sich ohne visuelle Tests vollständig abdecken.
Renderless & Headless in Vue — Das Wichtigste auf einen Blick
Renderless Component
Kein eigenes Markup. State und Aktionen via Scoped Slot. Konsument kontrolliert das Template vollständig.
Headless Pattern
Verhalten und A11y eingebaut, kein Styling. Beliebig viele Präsentationsschichten über einem Headless-Kern.
Composable
Reine Logik ohne Template. Leichtgewichtig, direkt in setup() integrierbar. Beste Testbarkeit.
provide/inject
Für tief verschachtelte Headless-Bäume. InjectionKey für TypeScript-Typsicherheit. Readonly-Wrapping verhindert direkte Mutationen.