Behebung der React-Prop-Überlastung mit Komposition und Slots
Erfahren Sie, warum React-Props mit hohem Konfigurationsaufwand zu Wartungsschulden führen, und wie Inversion of Control, Komposition sowie Slots wirklich wiederverwendbare Komponenten erzeugen.
Quellen: Your React Components Aren’t Reusable. They’re Just Complicated
Ein Blick darauf, wie konfigurationsgesteuerte Eigenschaften stillschweigend Wartungsschulden anhäufen und wie die Inversion der Kontrolle in Kombination mit Komposition zu wirklich modularen UI-Komponenten führt.
Stellen Sie sich einen Pull Request von einem Teamkollegen vor, der das, was er stolz als "Universal Data Table." bezeichnet, einbringt.
Ziel ist es, alle Anforderungen an die Darstellung von Tabellen im gesamten Codebase auf einmal zu erfüllen.
Dafür werden dreiunddreißig separate Eigenschaften verwendet:
<UniversalDataTable
data={users}
columns={columns}
enablePagination={true}
paginationType="cursor"
enableSorting={true}
showFilterRow={true}
hasExportToCsvButton={true}
exportButtonText="Download CSV"
headerBackground="light-gray"
denseRows={false}
enableRowSelection={true}
selectionMode="multiple"
renderCustomRowActions={(row) => <DropdownMenu row={row} />}
onRowClick={(row) => navigate(`/users/${row.id}`)}
/>
Im Hintergrund befinden sich etwa 1.200 Zeilen an Logik.
Zur Darstellung einer Liste von Benutzern funktioniert es hervorragend.
Eine paar Wochen später bittet das Marketing um eine Preisvergleichstabelle für die öffentliche Website.
Die Anforderungen lauten: drei Spalten, Häkchen-Icons neben jeder Funktionszeile sowie ein hervorgehobener „Popular“-Umriss um die mittlere Spalte.
Jemand greift nach <UniversalDataTable /> in der Erwartung, dass es einfach funktioniert.
Dann gerät alles aus den Fugen.
Es gibt keine Möglichkeit, die Ränder der einzelnen Zellen anzupassen.
Die Ausrichtung der Überschriften ist in einem bestimmten Flex-Layout fest vorgegeben.
Um ein sauberes Ergebnis zu erzielen, muss man den CSV-Export-Button deaktivieren und dabei hasExportToCsvButton={false} sowie einige weitere Einstellungen übergeben.
Deshalb fügt das Team noch vier weitere Eigenschaften hinzu:
isPricingTable
popularColumnIndex
customHeaderRenderer
disableGlobalStyles
Die Datei wächst auf 1.450 Zeilen an.
Zu diesem Zeitpunkt ist sie überhaupt nicht mehr wiederverwendbar.
Was ursprünglich als Abstraktion begann, ist zu einem Gewirr aus Sonderfällen geworden, die vorgeben, ein einziger Komponent zu sein.
1. Die Falle der Konfigurations-Eigenschaften
Wenn ein Team möchte, dass eine Komponente „wiederverwendbar“ ist, ist der instinktive Ansatz in der Regel die Konfiguration über Eigenschaften.
Jeder neue Anwendungsfall führt dazu, dass weitere Flags, String-Enum-Werte, Callbacks oder Style-Überwrites zur props-Schnittstelle hinzugefügt werden.
Ein typisches Beispiel:
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
hasLeftIcon?: boolean;
leftIcon?: React.ReactNode;
hasRightIcon?: boolean;
rightIcon?: React.ReactNode;
isFullWidth?: boolean;
tooltipText?: string;
tooltipPosition?: 'top' | 'bottom';
rounded?: boolean;
}
Auf den ersten Blick scheint dies anpassungsfähig zu sein.
Das Problem ist jedoch, dass die auf Konfiguration basierende Flexibilität einen Kostenfaktor mit sich bringt, den man nicht sofort erkennt.
1. Kombinatorische Explosion
Schon zehn boolesche Props ergeben theoretisch 1.024 mögliche Kombinationen.
Man muss sie nicht alle testen, um zu erkennen, wie schnell die Situation außer Kontrolle gerät.
Besondere Kombinationen widersprechen einander.
Andere bleiben einfach für immer ungenutzt.
Wieder andere liefern je nach Breite des Ansichtsfensters, umgebendem Inhalt oder der Nesttiefe des Components inkonsistente Ergebnisse.
Mit der Zeit entsteht so eher ein Haufen Randfälle als eine kohärente, vorhersehbare API.
2. Prop-Drilling
Sagen wir, eine Einstellung für ein Tooltip muss einen mehrfach verschachtelten Wrapper erreichen, der tief im Komponentenbaum versteckt ist.
Jede Zwischenkomponente auf dem Weg muss nun eine Eigenschaft weiterleiten, die sie eigentlich nicht benötigt.
Die Konfiguration wird ausschließlich wegen der Struktur durch die verschiedenen Ebenen geleitet – nicht, weil sie dort sinnvoll ist.
Das ist das Prinzip hinter dem sogenannten Prop-Drilling.
3. Starre Layout-Voraussetzungen
Genau hier versagen konfigurationsgesteuerte Komponenten in der Regel endgültig.
Nehmen wir an, ein neues Design erfordert ein Icon zwischen zwei Zeilen mit Button-Text.
Dann könnte man etwas wie folgt hinzufügen:
iconPosition="between"
was vorübergehend helfen mag.
Aber sobald sich das Design wieder ändert, was dann?
Bald schon muss man eine ganze Reihe von Varianten verwalten:
iconPosition="left"
iconPosition="right"
iconPosition="top"
iconPosition="between"
iconPosition="absolute"
Die Liste wächst weiter, weil die interne JSX-Struktur des Komponenten weiterhin das Layout vollständig bestimmt.
Das eigentliche Problem liegt nicht darin, wie viele Props vorhanden sind.
Sondern darin, dass die Komponente zu viele Verantwortlichkeiten für Dinge übernimmt, die nicht zu ihrem Aufgabenbereich gehören.
2. Kontrollumkehr: Ein besseres Denkmodell
Echte Wiederverwendbarkeit wird nicht erreicht, indem man alle zukünftigen Anforderungen im Voraus vorhersuchen will.
Sie entsteht durch die Anwendung der Kontrollumkehr (IoC).
Das Konzept ist einfach:
Anstatt dass die Komponente alles bestimmt, was innerhalb von ihr dargestellt wird, sollte derjenige, der die Komponente verwendet, die Teile steuern, die geändert werden müssen.
Deshalb ist Komposition in React ein so starkes Muster.
Anstatt einen Button zu erstellen, der bereits über Funktionen für Tooltips, Icons, Spinnern, Abzeichen sowie alle möglichen Layout-Kombinationen verfügt, sollte man einen Button bauen, dessen Hauptaufgabe einfach darin besteht, ein Button zu sein.
Zum Beispiel:
export function Button({
children,
variant = 'primary',
className = '',
...props
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} ${className}`}
{...props}
>
{children}
</button>
);
}
Die Kontrolle über den Inhalt liegt nun beim Benutzer:
<Tooltip content="Save your profile">
<Button>
<SaveIcon />
<span>Save Changes</span>
</Button>
</Tooltip>
Beachten Sie, was nicht mehr notwendig ist.
Der Button hat keinerlei Kenntnis davon, dass Tooltips existieren.
Es gibt keine hasLeftIcon-Eigenschaft.
Es gibt keine leftIcon-Eigenschaft.
Es gibt keine iconSpacing-Eigenschaft.
Es gibt keine hasSpinner-Eigenschaft.
Braucht man einen Lade-Spinner? Dann platziert man einfach einen direkt innerhalb des Buttons.
<Button>
<Spinner />
Saving...
</Button>
Die Komponente wird kleiner, gewinnt aber an Flexibilität.
Dieser Kompromiss ist hier der entscheidende Aspekt.
Ein wiederverwendbarer Komponent muss nicht intern jedes mögliche Szenario handhaben. Er benötigt eine klar definierte Grenze, gegen die andere Komponenten aufgebaut werden können.
3. Slots machen komplexe Komponenten erweiterbarer
Die Verwendung von children funktioniert gut für einfache Elemente. Doch wie sieht es mit komplexeren Komponenten wie Karten, Dialogfeldern, Dashboards oder Profilansichten aus?
Darum kommen Slots und zusammengesetzte Komponenten ins Spiel.
Stellen Sie sich eine herkömmliche Profilkarte vor, die aus einer Reihe von Eigenschaften besteht:
<UserProfileCard
avatarUrl="/avatar.jpg"
name="Sarah Connor"
role="Tech Lead"
status="Active"
bio="Building reliable frontend systems."
primaryActionLabel="Send Message"
onPrimaryAction={sendMessage}
secondaryActionLabel="View Profile"
onSecondaryAction={viewProfile}
badgeColor="green"
/>
Auf den ersten Blick erscheint das praktisch.
Dann ändern sich die Anforderungen jedoch.
Das Marketing-Team möchte, dass die sozialen Profilangaben der Person direkt neben ihrem Namen angezeigt werden. Das Verwaltungsteam für Unternehmenskonten benötigt ein „Suspended“-Banner, der über die Karte gestreckt wird. Ein anderes Produktteam möchte statt zwei drei Aktionsschaltflächen. Ein Designer entscheidet, dass das Avatar auf der rechten statt auf der linken Seite platziert werden sollte.
Jeder dieser Anfragen führt zu einem weiteren Element, das berücksichtigt werden muss.
Ein besseres Vorgehen besteht darin, das Layout als zusammensetzbare Komponenten darzustellen:
<Card>
<Card.Header>
<Avatar
src={user.avatarUrl}
alt={user.name}
/>
<div>
<Card.Title>{user.name}</Card.Title>
<Card.Subtitle>{user.role}</Card.Subtitle>
</div> <Badge variant={user.statusColor}>
{user.status}
</Badge>
</Card.Header> <Card.Body>
<p>{user.bio}</p>
</Card.Body> <Card.Actions>
<Button
variant="secondary"
onClick={viewProfile}
>
View Profile
</Button> <Button
variant="primary"
onClick={sendMessage}
>
Send Message
</Button>
</Card.Actions>
</Card>
Nun kann man tatsächlich die Struktur der Karte im JSX sehen. Und weil man sie sieht, kann man sie auch ändern.
Möchte man eine weitere Aktionsschaltfläche? Fügen Sie sie einfach hinzu. Braucht man ein anderes Abzeichen? Ersetzen Sie es. Muss man einen weiteren Banner hinzufügen? Fügen Sie ihn dort ein, wo er hingehört. Möchte man eine völlig andere Anordnung des Headers? Schreiben Sie einfach einen neuen Header.
Dafür ist es nicht notwendig, an einem überdimensionierten gemeinsamen Komponenten zu arbeiten, um nur eine einzige neue Variante zu unterstützen.
4. Komposition statt Konfiguration
Diese beiden Ansätze stellen grundlegend unterschiedliche Fragen.
Konfiguration fragt:
"Welche Optionen sollte diese Komponente bereitstellen?"
Komposition fragt:
"Welche kleinen Bausteine können die Nutzer zu dem zusammenstellen, was sie tatsächlich benötigen?"
Durch Konfiguration wird ständig mehr Verantwortung für Entscheidungen auf die Komponente selbst abgewälzt. Bei der Komposition werden diese Entscheidungen wieder an diejenigen zurückgegeben, die die Komponente verwenden. Das ist in der Regel ein geeigneterer Ort für diese Verantwortung.
Nehmen Sie diesen Vergleich als Beispiel:
<Button
hasLeftIcon
leftIcon={<SaveIcon />}
hasTooltip
tooltipText="Save your profile"
showSpinner={isSaving}
spinnerPosition="left"
/>
gegenüber:
<Tooltip content="Save your profile">
<Button>
{isSaving && <Spinner />}
<SaveIcon />
Save Profile
</Button>
</Tooltip>
Zwar enthält der zweite Codeausschnitt mehr JSX. Gleichzeitig gibt es dort jedoch eine deutlich geringere Kopplung zwischen Teilen, die nichts voneinander wissen müssen. Dieser Tausch lohnt sich in der Regel – etwas zusätzlicher Markup an der Aufrufstelle ist langfristig gesehen billiger als die Hinzufügung einer weiteren dauerhaften Funktion zu einem gemeinsamen Komponenten.
5. Lernen von headless Bibliotheken
Mehrere moderne Frontend-Bibliotheken veranschaulichen diese Philosophie außergewöhnlich gut.
Projekte wie Radix UI, TanStack Table und shadcn/ui haben dazu beigetragen, diese auf Komposition ausgerichtete Denkweise zu verbreiten.
TanStack Table ist eines der klarsten Beispiele. Es bindet Sie nicht an ein festes Tabellendesign. Stattdessen stellt es die Logik sowie das Zustandsmanagement für Aspekte wie folgende bereit:
- Sortieren
- Filtern
- Paginieren
- Zeilenauswahl
Die Umsetzung dieser Einstellungen in tatsächliche Markup-Strukturen liegt ganz bei Ihnen. Zum Beispiel:
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{/* Your own rendering logic */}
</tbody>
</table>
Die genaue Form der API ist hier nicht wirklich entscheidend. Entscheidend ist vielmehr die zugrunde liegende Architektur: Der Tabellenmotor steuert das Verhalten, während Sie für die Darstellung verantwortlich sind. Da diese beiden Aspekte getrennt sind, können Sie die Visualisierung frei umgestalten, ohne dass die Kernlogik der Tabelle zu einem riesigen visuellen Komponenten wird.
6. Verhalten von Aussehen trennen
Dies führt natürlich zu einer weiteren Richtlinie, der man folgen sollte:
Trennen Sie das, was eine Komponente tut, von der Art und Weise, wie sie dargestellt wird.
Eine einzelne Komponente muss möglicherweise mit erheblicher interner Komplexität umgehen, beispielsweise bei:
- Tastaturnavigation
- Fokusbereitung
- Auswahl
- Sortierung
- Filtern
- Zustand
- Barrierefreiheit
- Positionierung
Alles das gut zu handhaben, ist selten einfach. Dennoch zwingt nichts davon denselben Komponenten dazu, auch genau vorzuschreiben, wie das Endergebnis aussehen soll.
Das ist die Kernidee hinter den meisten headless UI-Systemen. Das Verhalten wird in vielen visuellen Implementierungen wiederverwendet, während die Präsentationsschicht völlig offen bleibt. Diese Trennung führt in der Regel zu einer weitaus stärkeren Wiederverwendbarkeit als das Versuch, einen riesigen Komponenten mit Dutzenden visueller Konfigurationsmöglichkeiten zu erstellen.
7. Wann Props wirklich sinnvoll sind
Das bedeutet keineswegs, dass Props ein schlechtes Muster sind.
Sie gehören zu den grundlegendsten Werkzeugen, die React zur Verfügung stellt.
Das Problem liegt nicht darin, dass Props existieren. Es liegt darin, sie zu verwenden, um jede denkbare strukturelle Variation zu kodieren, die eine Komponente benötigen könnte.
Beispiele wie diese sind völlig in Ordnung:
<Button variant="primary" />
<Button size="sm" />
<Input disabled />
<Card className="featured" />
Die rote Flagge erscheint, sobald ein Komponente anfängt, eine solche Liste anzuhäufen:
<Component
showHeader
showFooter
showBadge
showIcon
showActions
iconPosition="left"
badgePosition="right"
footerAlignment="center"
actionLayout="horizontal"
compact
dense
bordered
rounded
disableAnimation
customHeaderRenderer={...}
customFooterRenderer={...}
/>
Sobald man dieses Muster erkennt, lohnt es sich, innezuhalten und zu fragen:
">Sollten diese wirklich als Props bleiben, oder würden einige davon besser als zusammengesetzte Kinderkomponenten funktionieren?"
Allein die Stellung dieser Frage verhindert oft unnötige Komplexitäten späterhin.
8. Was Sie durch übermäßige Abstraktion wirklich kostet
Der eigentliche Schaden durch übermäßig konfigurierte Komponenten ist nicht ästhetischer Natur.
Sondern darin, dass sie teuer in der Anpassung werden.
Eine weithin genutzte Komponente hat einen großen Einflussbereich. Wenn zwanzig Stellen in Ihrer Codebasis von ihr abhängen, kann jede neue Eigenschaft, die hinzugefügt wird, Auswirkungen auf alle zwanzig Nutzerkomponenten haben.
Ingenieure zögern dann, sie anzufassen.
Die Erstellung von Dokumentationen dafür wird schwieriger.
Das Testen davon wird schwieriger.
Das Aufspüren von Fehlern darin wird schwieriger.
Irgendwann wird das Komponenten so vollständig „wiederverwendbar“, dass niemand sie tatsächlich wiederverwenden möchte.
Das ist die Ironie, die in diesem Muster verborgen liegt.
Übermäßige Abstraktion kann gerade die Wiederverwendbarkeit untergraben, die sie eigentlich bieten soll.
Eine kleine, eng abgegrenzte Primitive ist in der Praxis oft wiederverwendbarer als eine umfangreiche Komponente, die darauf ausgelegt ist, jede mögliche Variante vorzusehen.
Ein praktisches Framework für die Erstellung von Komponenten
Bevor Sie zu einer weiteren Konfigurationseigenschaft greifen, führen Sie diese Überprüfungen durch.
1. Habe ich es mit Verhalten oder mit Darstellung zu tun?
Falls es sich um Verhaltensaspekte handelt, sind wahrscheinlich ein Prop oder ein benutzerdefinierter Hook das richtige Werkzeug. Wenn es ausschließlich um die visuelle Struktur geht, eignet sich in der Regel die Komposition besser.
2. Braucht dieses Component tatsächlich diese Informationen?
Falls von einem einzelnen Component erwartet wird, dass es Tooltips, Analytics-Hooks, Abzeichen, Icons, Ladeindikatoren sowie jede Anordnung seiner Kinder versteht, hat es fast sicher mehr Verantwortung übernommen, als nötig ist.
3. Könnte stattdessen children das bewältigen?
Falls ja, sollte man der Komposition den Vorzug geben anstelle des Einführens eines weiteren, auf Inhalt ausgerichteten Props.
4. Könnten benannte Slots die API klarer machen?
Für komplexere Components macht das Definieren expliziter Bereiche wie:
<Card.Header />
<Card.Body />
<Card.Footer />
es deutlich einfacher, die Struktur zu verstehen.
5. Kann die Logik von der Markup-Struktur getrennt werden?
Falls dem so ist, lohnt es sich, einen headless-Ansatz oder einen auf Logik ausgerichteten Ansatz in Betracht zu ziehen.
6. Beantwortet dieses Feature eine reale oder nur eingebildete Notwendigkeit?
Diese Frage ist wichtiger, als es scheint.
Wehren Sie sich dagegen, etwas hinzuzufügen wie:
futureFeature={true}
nur weil jemand es vielleicht in Zukunft haben möchte.
Entwerfen Sie anhand von Anforderungen, für die Sie tatsächlich Beweise haben, und nicht anhand von Vermutungen.
Zusammenfassende Gedanken
Der Zweck beim Erstellen wiederverwendbarer React-Komponenten besteht nicht darin, etwas zu schaffen, das absolut alles können muss.
Es geht darum, Komponenten zu erstellen, die auf viele verschiedene Weisen zusammengesetzt werden können.
Der Unterschied mag subtil klingen, doch architektonisch verändert er alles.
Ein Komponent mit dreißig Props mag auf den ersten Blick flexibel erscheinen, doch jeder dieser Props bringt weitere Verantwortlichkeiten, einen weiteren Zustand, den berücksichtigt werden muss, sowie eine weitere Kombination mit sich, die irgendwann unterstützt werden muss.
Dagegen mag eine Komponente mit einer schmalen API und klaren Zusammensetzungsgrenzen unscheinbar wirken.
In der Praxis hält sie jedoch im Laufe der Zeit in der Regel viel besser stand.
Nächstes Mal, wenn Sie versucht sind, einem gemeinsam genutzten Komponenten noch einen weiteren Prop hinzuzufügen, halten Sie kurz inne und fragen Sie sich:
">Mache ich diese Komponente nutzbarer, oder nur konfigurierbarer?"
Diese beiden Aspekte sind nicht austauschbar.
Eine Komponente wird nicht durch das Anhäufen von dreißig Optionen nutzbarer – sie wird nutzbar, indem sie eine Aufgabe gut erledigt, sinnvolle Grenzen vorgibt und ansonsten nicht stört.
Eine solide Komponentenarchitektur dreht sich nicht darum, alle zukünftigen Anforderungen im Voraus vorherzusagen.
Sie zielt darauf ab, alle zukünftig auftretenden Anforderungen einfach zusammenzusetzen zu ermöglichen.