Aufbau einer React-Komponente

Aufbau einer React-Komponente

95

8 min.

Anatomie einer React-Komponente

Die meisten Teams haben eine Vorstellung davon, wie eine „richtige" Komponente aussehen sollte, aber meistens lebt sie in Köpfen und Code-Reviews, nicht in Textform. Hier versuche ich, eine konkrete Komponentenstruktur vollständig festzuhalten — vom Ordner, in dem sie liegt, bis zur Export-Zeile.

Ordner und Props-Datei

Eine Komponente belegt einen eigenen Ordner mit zwei Dateien:

product-card/
  product-card.tsx
  product-card.props.ts

Die Props-Typen liegen getrennt von der Implementierung, und dafür gibt es zwei Gründe.

Der erste ist der Umfang: ein zusammengesetztes Props aus Pick, Schnittmengen und Query-Typen wächst leicht auf Dutzende Zeilen an und beginnt in derselben Datei, die Komponente selbst zu verdecken. Der zweite sind die Importe: den Vertrag der Komponente braucht nicht nur sie selbst, er wird von Tests, Wrappern und den Props übergeordneter Komponenten gezogen, und ihnen allen genügt die leichte Typ-Datei, ohne die Implementierungsdatei und deren Abhängigkeiten.

Der Typ selbst heißt schlicht Props, der Dateiname enthält bereits den Namen der Komponente, daher würde ProductCardProps innerhalb von product-card.props.ts nichts hinzufügen.

import type { Props } from './product-card.props';

Diese Benennung hat auch einen Nachteil: öffnet man mehrere Props-Dateien nebeneinander, lassen sie sich am Typnamen allein nicht unterscheiden — man muss auf den Tab schauen, und beim Import mehrerer Props in eine Datei kollidieren die Namen, hier hilft das Umbenennen über asimport type { Props as CardProps } from ....

Props werden zusammengesetzt, nicht geschrieben

Ein Props-Typ besteht fast nie aus Feldern, die von Grund auf erfunden wurden, meist ist er aus mehreren Quellen zusammengeklebt — native HTML-Attribute und aus dem API-Schema generierte Typen.

type Props = Pick<ComponentProps<'a'>, 'className'> &
  Pick<ComponentProps<'img'>, 'fetchPriority'> & {
    product: ProductQuery['products'][number];
    onSelect?: ((id: string) => void) | undefined;
  };

Pick<ComponentProps<'a'>, 'className'> statt className?: string wirkt sperrig, erspart aber das manuelle Wiederholen von Typen, die in React bereits beschrieben sind, und Felder aus dem generierten Query-Typ ändern sich zusammen mit dem Schema — benennt das Backend ein Feld um, zeigt der Compiler alle betroffenen Stellen.

Ein eigenes Detail ist die Schreibweise ((id: string) => void) | undefined statt eines einfachen ?, sie ist bei aktiviertem exactOptionalPropertyTypes nötig, das Flag unterscheidet „das Feld fehlt" von „das Feld existiert, ist aber undefined", und der zweite Fall tritt ständig auf, wenn Props per Spread durchgereicht werden. Ohne das explizite | undefined kompilieren solche Aufrufe nicht, und es ist einfacher, einmal länger zu schreiben, als bedingte Spreads über alle Aufrufstellen zu verteilen.

Und die wichtigste Regel, die mehr zählt als die Syntax: ähnliche Komponenten sollten ihre eigenen schmalen Typen haben — wenn sich Produktkarte, Artikelkarte und Bewertungskarte in ihren Daten unterscheiden, sind das drei Komponenten mit drei Props, nicht eine Komponente mit einem Typ, in dem die Hälfte der Felder optional ist. Ein Typ, in dem rating?, author? und videoUrl? ohne jeden Zusammenhang nebeneinanderstehen, beschreibt keinen Vertrag mehr — er beschreibt die Vereinigung aller Fälle. Das ist im Kern das Interface-Segregation-Prinzip aus SOLID, angewandt auf Props — eine Komponente sollte keine Felder deklarieren, die nur eines ihrer Szenarien braucht.

Reihenfolge im Rumpf

Importe oben und die Komponentendeklaration direkt danach — hier nichts Besonderes, das Interessante beginnt im Rumpf. Auch er hat eine feste Blockreihenfolge, dank derer eine bestimmte Art von Logik an einem bekannten Ort gefunden wird, statt die ganze Datei zu lesen.

const Dropdown = (props: Props) => {
  // 1. Props-Destrukturierung
  const { onChange, options, value } = props;

  // 2. React-Hooks
  const [isOpen, setIsOpen] = useState(false);

  const listRef = useRef<HTMLUListElement>(null);

  // 3. Variablen
  const selected = options.find((option) => option.value === value);
  const displayValue = selected?.label ?? 'Auswählen';

  // 4. Funktionen
  const handleSelect = (nextValue: string) => {
    onChange(nextValue);
    setIsOpen(false);
  };

  // 5. useEffect und useLayoutEffect — zuletzt, direkt vor return
  useEffect(() => {
    if (!isOpen) return;

    const handleClickOutside = () => setIsOpen(false);

    document.addEventListener('click', handleClickOutside);

    return () => document.removeEventListener('click', handleClickOutside);
  }, [isOpen]);

  return (/* ... */);
};

export { Dropdown };

Destrukturierung → Hooks → Variablen → Funktionen → Effekte. Die Reihenfolge ist nicht willkürlich, jeder folgende Block darf sich auf die vorherigen stützen, aber nicht umgekehrt: eine Variable nutzt einen Hook, eine Funktion nutzt Variable und Hook, ein Effekt nutzt alles, was darüber steht.

Die umgekehrte Reihenfolge würde in JS gar nicht funktionieren (const kann nicht vor der Deklaration verwendet werden), durcheinander dagegen schon — und genau durcheinander sehen die meisten unlesbaren Komponenten aus, bei denen man auf und ab springen muss, um die Abhängigkeiten eines Effekts mitten in der Datei zu verstehen.

useEffect und useLayoutEffect bilden einen eigenen, letzten Block, statt bei den übrigen Hooks am Anfang zu stehen, denn Effekte hängen meist an dem, was weiter unten im Rumpf deklariert ist — an Handlern und berechneten Variablen, und einen Effekt vor dem zu lesen, worauf er sich bezieht, ist unbequem.

Die Variablen aus dem dritten Block sind alles, was aus Props oder State berechnet wird und vor dem JSX einen Namen bekommt.

const isHighPriority = fetchPriority === 'high';
const isOutOfStock = product.stock === 0;

Das Ziel ist, dass sich das Markup darunter wie eine Aufzählung bereits getroffener Entscheidungen liest, nicht wie der Ort, an dem sie getroffen werden, die Bedingung product.stock === 0 im JSX zwingt den Leser, die Logik an Ort und Stelle zu entwirren, isOutOfStock nicht.

Varianten — über ein Objekt, nicht über if

Hat eine Komponente visuelle Varianten, liegt die Versuchung nahe, Bedingungen über das Markup zu verteilen, besser ist es, die Varianten mit einer Tabelle zu beschreiben — sie erhöht die Cognitive Complexity nicht, egal wie viele Varianten hinzukommen.

const VARIANT_CONFIG = {
  primary: 'bg-accent text-white rounded-md',
  outline: 'border border-accent text-accent',
  ghost: 'text-black',
} satisfies Record<NonNullable<Props['variant']>, string>;

Die Tabelle wird auf Modulebene deklariert, zwischen den Importen und der Komponente, nicht in deren Rumpf, denn im Rumpf würde das Objekt bei jedem Render neu erzeugt, auf Modulebene entsteht es einmal beim Laden der Datei.

Dorthin wandert auch alles andere, was nicht von Props oder State abhängt.

Im JSX bleibt VARIANT_CONFIG[variant] — die einzige Stelle, an der die Variante überhaupt erwähnt wird.

Das satisfies am Ende leistet zwei Dinge. Erstens prüft es die Vollständigkeit — wird dem Typ variant ein neuer Wert hinzugefügt und die Zeile im Objekt vergessen, kompiliert der Code nicht. Zweitens löscht es, anders als die Annotation const CONFIG: Record<...> = {...}, die literalen Typen der Werte nicht, sie bleiben konkrete Strings statt eines abstrakten string.

Eine solche Tabelle ist das Open-Closed-Prinzip in seiner alltäglichen Form, eine neue Variante kommt als Zeile ins Objekt, das bestehende Markup ändert sich nicht. Das Gegenbeispiel ist eine Komponente, in deren Rumpf Flags wie isCompact, isFeatured, isInline verstreut sind und jeder JSX-Abschnitt sie in anderen Kombinationen prüft, eine solche Komponente lässt sich nicht lokal ändern, die Korrektur einer Variante schneidet durch das Markup aller übrigen.

Gemeinsamer Code wird über Slots ausgelagert

Wenn mehrere Komponenten ein Stück Markup teilen, will man es auslagern, entscheidend ist das Wie — die gemeinsame Komponente darf ihre Konsumenten nicht kennen, statt Flags werden deshalb Slots verwendet.

const CardCover = (props: Props) => {
  const { badge, image, overlay } = props;

  return (
    <div className='relative aspect-square'>
      {overlay}
      {image}
      {badge}
    </div>
  );
};

Ein Konsument legt in badge den Hinweis „nicht auf Lager", ein anderer einen Aufrufzähler, ein dritter übergibt nichts. CardCover weiß nichts von Lagerbeständen oder Aufrufen, wächst beim Auftauchen neuer Konsumenten nicht, und es behält einen einzigen Grund, sich zu ändern — das Layout des Covers selbst.

Das Kriterium für die Auslagerung ist dabei streng, ausgelagert wird, was wörtlich übereinstimmt, nicht was „ähnlich aussieht". Zwei Markup-Stücke, die in der Form ähnlich sind, sich aber aus verschiedenen Gründen weiterentwickeln, beginnen nach der Zusammenlegung, einander im Weg zu stehen, und ein halbes Jahr später lebt in der gemeinsamen Komponente ein Knäuel von Bedingungen — genau das, was die Auslagerung beseitigen sollte.

Hierher gehört auch die Server/Client-Grenze in Next.js, 'use client' kommt auf die konkrete Datei, die Events oder State braucht, im Paar aus präsentationaler Hülle und interaktivem Link steht die Direktive nur auf letzterem, ersteres wird weiter auf dem Server gerendert.

Styles

Die Klassen werden mit der Utility cn zusammengesetzt (ein Wrapper um clsx und tailwind-merge), jede Bedingung wird als eigenes Argument übergeben statt per Template-Literal in einen String geklebt.

className={cn(
  'flex flex-col rounded-md bg-white',
  isOutOfStock && 'opacity-60',
  className,
)}

Das externe className steht als letztes Argument, so erlaubt tailwind-merge der aufrufenden Seite, die Styles der Komponente zu überschreiben statt nur zu ergänzen.

Export am Ende

Die Komponente wird über const deklariert, der Export steht als eigene Zeile am Dateiende, immer benannt.

const ProductCard = (props: Props) => {
  // ...
};

export { ProductCard };

Der benannte Export statt default sorgt dafür, dass der Komponentenname an jeder Importstelle identisch ist und automatische Refactorings übersteht, und der Export am Ende statt in der Deklaration dafür, dass die öffentliche Oberfläche der Datei an einem Ort sichtbar ist.

Beide Konventionen sind klein, und der Nutzen jeder einzelnen ist gering, der Sinn entsteht daraus, dass sie überall eingehalten werden.

Wozu das alles

Keiner der Punkte oben ist neu, bemerkenswert ist etwas anderes — zusammen decken sie die Hälfte von SOLID ab: schmale Props statt eines fetten Typs — Interface Segregation, die Variantentabelle — Open/Closed, Slots ohne Wissen über Konsumenten — Single Responsibility.

Nur wird das nicht mit Klassenhierarchien erreicht, sondern mit Formdisziplin.

Wenn jede Datei gleich aufgebaut ist — Props separat, Destrukturierung am Anfang, Effekte vor dem return, Export am Ende — hört man in einem Projekt mit fünfzig Komponenten auf, den Code zu lesen, und beginnt, ihn wiederzuerkennen, Datei öffnen, die richtige Schicht finden, korrigieren.

Die Form einer Komponente wird so vorhersehbar wie ihr Verhalten, und genau das spart auf lange Sicht Zeit.

Ähnliche Kategorien:

Ähnliche Artikel

  • SSF-U – Einheitlicher Standard für Vollbildmodus

    Der SSF-U-Standard legt Anforderungen an die Semantik, Barrierefreiheit und Funktionslogik von Vollbildmodus fest

    31

    2 min.

  • SSA – Einheitlicher Standard für Akkordeons

    Der SSA-Standard legt Anforderungen an die Semantik, Barrierefreiheit und Funktionslogik von Akkordeons fest

    42

    2 min.

  • Bad Practices für Websites

    Analyse kritischer Fehler im Webdesign. Warum Slider, Autoplay und schwerfällige Seiten die Konversionsrate sowie die Platzierungen bei Google und Yandex verschlechtern

    43

    2 min.

  • SSP - Einheitlicher Standard für Paginierung

    Der SSP-Standard legt Anforderungen an die Semantik, Barrierefreiheit und Funktionslogik von Paginierung fest

    48

    1 min.

  • SSPS – Einheitlicher Standard für Projektstruktur

    Der SSPS-Standard legt Anforderungen an die Struktur und Benennung von Dateien und Ordnern im Projekt fest

    160

    2 min.

  • SSG – Einheitlicher Standard für Git

    Der SSG-Standard legt Anforderungen an die Semantik, Barrierefreiheit und Funktionslogik von Git fest

    47

    3 min.

  • Alle Artikel

Kontaktieren Sie mich