Die Steuerung von KI-Code-Agenten mit vier benannten React-Architekturmmustern
Erlernen Sie vier React-Muster (Seitenkomposition, benutzerdefinierte Hooks, polymorphe Komponenten, Reduzierer) sowie, wie das Benennen dieser Muster in Anfragen zu saubererem von KI erzeugtem Code führt.
Quellen: How I Get AI to Write Cleaner React Code with 4 Architecture Patterns
KI-Programmieragenten wie Claude oder OpenCode können mit nur wenigen Anweisungen eine funktionierende React-Funktion erstellen, und der Code erfüllt in der Regel die gewünschten Anforderungen. Die Schwachstelle liegt jedoch in der Struktur: Erstellte Komponenten umfassen oft Hunderte von Zeilen, in denen Markup, Daten, Effekte und Handler in einer Datei vermischt sind. Wenn man den Agenten bittet, „es modular zu gestalten“, entstehen meist fragmentierte Lösungen für bestimmte Seiten statt wirklich wiederverwendbarer Bausteine. Dieser Artikel behandelt vier Muster, die diese Strukturprobleme beheben, und zeigt außerdem, wie man sie in den Anweisungen benennt, damit der Agent die von einem gewünschte Architektur statt eine selbst auszuwählen.
Warum funktionierender Code nicht dasselbe ist wie eine gute Architektur
Eine gängige Richtlinie für sauberen Code besteht darin, eine React-Komponente auf etwa 100 Zeilen zu beschränken, wobei 200 als praktische Obergrenze gelten. Es handelt sich dabei um eine Heuristik und nicht um ein Gesetz – doch Dateien mit 500 Zeilen oder mehr sind genau der Punkt, an dem die Ergebnisse künstlicher Intelligenz ohne Eingriff aus dem Ruder laufen.
Die offensichtliche Reaktion besteht darin, den Agenten zu bitten, die Komponente in kleinere Teile aufzuteilen. In der Regel tut er dies, indem er die Benutzeroberfläche in mehrere kleinere Komponenten innerhalb eines für diese Seite bestimmten Verzeichnisses aufteilt. Diese Teile sind zwar kleiner, bleiben aber weiterhin an eine einzige Funktion gebunden und sind selten anderswo einsetzbar. Das ist Dekomposition, keine Modularität.
Für ein kleines Projekt spielt das kaum eine Rolle. Für Software, die jahrelang weiterentwickelt werden muss, bedeutet das, dass jede Änderung Code betrifft, der ursprünglich nicht dafür konzipiert war, wiederverwendet oder erweitert zu werden. Die Lösung besteht nicht darin, die Verwendung von KI einzustellen. Vielmehr geht es darum, die Designmuster zu kennen, die eine React-Codebasis skalierbar machen, und den Agenten damit zu steuern. Die Codegenerierung wird immer günstiger; die Entscheidung darüber, wie der Code strukturiert werden soll, bleibt weiterhin Sache des Entwicklers. Wenn Sie einen umfassenderen Überblick darüber erhalten möchten, wie KI-Ergebnisse oft die Prinzipien des sauberen Codes ignorieren, lesen Sie „Wenn KI Ihre React-App schreibt, aber die Prinzipien des sauberen Codes ignoriert“.
Muster 1: Abschnitte in vier Komponenten aufteilen
Fragen Sie einen Entwickler nach einem UI-Bereich – in der Regel wird dabei der Wrapper, die Überschrift, die Darstellung der Liste sowie die einzelnen Elemente in ein einziges Komponenten-Element zusammengefasst. Die Duplikation wird deutlich, je größer die Anwendung wird, denn dieselbe Struktur taucht fast überall wieder auf: Blogs, Online-Shops, Portfolios, Verzeichnisse, SaaS-Landingpages und Dashboards.
Stellen Sie sich eine E-Commerce-Startseite vor, die Bereiche für neue, aktuelle und beliebte Produkte enthält. Jeder Bereich sieht identisch aus; nur die Daten unterscheiden sich. Jeder dieser Bereiche lässt sich in vier Teile aufteilen, von denen jeder eine bestimmte Aufgabe hat:
- einen Bereichs-Wrapper
- eine Bereichs-Überschrift
- eine Liste von Karten
- eine einzelne Karte
Der Bereich
Der Bereich ist das Elternelement. Er nimmt Daten über Props entgegen und gibt sie an seine Kinder weiter – er kennt also die Struktur, aber keine Details zur Gestaltung.
export function ProductSection({ title, subtitle, products }) {
return (
<section>
<SectionHeading
title={title}
subtitle={subtitle}
/>
<ProductList products={products} />
</section>
)
}
Die Überschrift
Ein spezielles Überschriftskomponente sorgt dafür, dass das Styling von Titel und Untertitel an einem Ort gespeichert wird, sodass jede Abschnitt in der Anwendung einheitlich aussieht, ohne dass jemand die Markup-Struktur erneut erstellen muss.
export function SectionHeading({ title, subtitle }) {
return (
<div>
<h2>{title}</h2>
<p>{subtitle}</p>
</div>
)
}
Die Liste
Die Liste hat nur eine einzige Aufgabe: Sie durchläuft die Sammlung und zeigt für jeden Eintrag eine Karte an, wobei dabei stets derselbe key verwendet wird.
export function ProductList({ products }) {
return (
<div className="grid">
{products.map(product => (
<ProductCard
key={product.id}
product={product}
/>
))}
</div>
)
}
Bemerken Sie, dass sie keine Ahnung hat, wie ein Produkt aussieht. Sie kennt lediglich die Vorgehensweise zur Anzeige einer Liste, was es ermöglicht, sie später mit einer anderen Karte wiederverwendet zu werden.
Die Karte
Die Karte zeigt nur einen Eintrag an und nichts weiter.
export function ProductCard({ product }) {
return (
<div>
<img src={product.image} />
<h3>{product.title}</h3>
<p>${product.price}</p>
</div>
)
}
Die Darstellung ist ihr einziges Anliegen. Im echten Code würde man dem Bild außerdem ein sinnvolles alt-Attribut geben, was im minimalen Beispiel weggelassen wird.
Vergleich der beiden Ansätze
Lässt man alles bei den Standardeinstellungen, neigt ein Agent dazu, etwas Ähnliches wie folgt zu erzeugen:
function Products({ products }) {
return (
<section>
<h2>Latest Products</h2>
<div className="grid">
{products.map(product => (
<article key={product.id}>
<img src={product.image} />
<h3>{product.title}</h3>
<p>${product.price}</p>
</article>
))}
</div>
</section>
)
}
Technisch gesehen ist damit nichts falsch. Die Probleme treten erst später auf, wenn eine andere Seite denselben Überschriftsstil, dasselbe Kartendesign oder dieselbe Gitterstruktur benötigt. In diesem Fall muss entweder die Markup-Struktur dupliziert werden oder Komponenten aus einer bereits aufgrund der Duplikation gewachsenen Codebasis extrahiert werden.
Durch die Aufteilung in vier Komponenten reduziert sich die Seite selbst auf Daten und Komposition:
function HomePage() {
return (
<>
<ProductSection
title="Hot Products"
subtitle="Most popular products right now"
products={hotProducts}
/>
<ProductSection
title="New Products"
subtitle="Recently added to our store"
products={newProducts}
/>
<ProductSection
title="Trending Products"
subtitle="What everyone is buying"
products={trendingProducts}
/>
<ProductSection
title="Discounted Products"
subtitle="Best deals this week"
products={discountedProducts}
/>
</>
)
}
Die Dateistruktur bleibt klein und vorhersehbar:
components/
│
├── SectionHeading.tsx
├── ProductCard.tsx
├── ProductList.tsx
└── ProductSection.tsx
Jeder Baustein hat nun einen klaren Platz. Derselbe Überschriftstyp funktioniert in jeder Abschnitt, eine weitere Produktzeile ist einfach ein weiterer ProductSection, und eine völlig neue Seite kann aus denselben Bestandteilen zusammengestellt werden.
Aufforderung dazu
Anstatt vom Agenten zu verlangen, einen Produktbereich zu erstellen, beschreiben Sie die Struktur ausführlich: „Verwenden Sie hier das 4-Componente-UI-Muster: eine ProductSection, die einen SectionHeading und eine ProductList kombiniert, wobei die ProductList ProductCard-Elemente anzeigt, wobei jede Komponente genau eine Aufgabe erfüllt.“ Dieser einzige Satz verändert die Struktur des Ergebnisses, sodass dieses viel leichter lesbar, wiederverwendbar und wartbar ist.
Muster 2: Logik in benutzerdefinierte Hooks verlagern
Die meisten React-Entwickler kennen benutzerdefinierte Hooks, doch wenn ein Agent den Code tippt, werden sie leicht vergessen. Wenn nach einem neuen Bildschirm gefragt wird, legt ein Agent in der Regel State, Effects, API-Aufrufe und Event-Handler innerhalb der Komponente ab. Nach einigen Iterationen erreicht die Datei 300 Zeilen, weil UI und Logik miteinander vermischt sind.
Es ist manchmal in Ordnung, die Logik im Komponentencode zu belassen. Sobald dieselbe Logik an anderer Stelle benötigt wird oder die Darstellung der Komponente verunklart, ist es in der Regel besser, einen Hook zu erstellen. Typische Beispiele sind:
useDebounce()zur Verzögerung der Sucheingabe vor dem Senden einer AnfrageuseApi()zur Handhabung des Abrufens sowie der Lade- und FehlerzuständeuseLocalStorage()zur Speicherung des Zustands im BrowseruseClickOutside()zum Schließen von Dropdowns oder Modalen, wenn der Benutzer anderswo klicktusePagination()zur Verwaltung des SeitenzustandsuseToggle()für boolesche Zustände wie eine geöffnete Seitenleiste
Suchlogik innerhalb der Komponente
Betrachten wir ein Produktsuchfeld. Eine typische generierte Version speichert sowohl die ursprüngliche Abfrage als auch eine verzögerte Kopie im Zustand, verwendet einen Effekt mit einem Timer, um den verzögerten Wert zu aktualisieren, sowie einen zweiten Effekt, um bei jeder Änderung neue Daten abzurufen:
function SearchProducts() {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
if (!debouncedQuery) return;
fetch(`/api/products?search=${debouncedQuery}`);
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
)
}
Das funktioniert. Doch wenn drei Bildschirme dasselbe Verzögerungsverhalten benötigen, muss man es entweder dreimal kopieren oder später Zeit und Ressourcen investieren, um es extrahieren zu lassen. Zwei weitere Punkte zur Robustheit: Die Abfrage sollte vor der Einbettung in eine URL mit encodeURIComponent übergeben werden, und ein echter Abruf muss unsortierte Antworten handhaben – beispielsweise mit einem AbortController bei der Aufräumphase des Effekts.
Extrahieren eines Verzögerungshooks
Die Timer-Logik wird in einen eigenen Hook verlagert, der einen Wert und eine Verzögerungszeit entgegennimmt und den verzögerten Wert zurückgibt:
// hooks/useDebounce.ts
export function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
Die Aufräumfunktion löscht den ausstehenden Timer jedes Mal, wenn sich der Wert vor Ablauf der Verzögerung ändert – genau das macht sie zu einem Debounce-Handler statt zu einem einfachen Verzögerungsmechanismus. Die Aufnahme von delay in den Abhängigkeitsarray sorgt dafür, dass der Hook weiterhin korrekt funktioniert, falls ihn ein Aufrufer ändert.
Dadurch beschränkt sich die Komponente auf ihren eigentlichen Zweck:
function SearchProducts() {
const [query, setQuery] = useState("")
const debouncedQuery = useDebounce(query)
useEffect(() => {
if (!debouncedQuery) return
fetch(`/api/products?search=${debouncedQuery}`)
}, [debouncedQuery])
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
}
Das Rendering bleibt in der Komponente, während die Debouncing-Funktion im Hook liegt – jeweils mit einer klaren Verantwortung.
Wann ein Hook sinnvoll ist
Nicht jeder Aufruf von useState() erfordert einen eigenen Hook. Gute Anzeichen dafür sind:
- dieselbe Logik tritt bereits in mehreren Komponenten auf oder wird dort auftreten;
- die Komponente wird zu lang;
- die Funktion kombiniert mehrere Aufrufe von
useState(), Effects oder Ereignishandler;
Aufforderung dazu
Weil KI-Systeme standardmäßig die Logik inline speichern, sollte man dies ausdrücklich angeben: „Platzieren Sie das wiederverwendbare Verhalten für diese Funktion in benutzerdefinierten Hooks, beispielsweise useApi(), useDebounce() oder useLocalStorage(), und überlassen Sie dem Komponenten nur die Verantwortung für das Markup.“ Diese allgemeine Regel gilt für alle vier Muster: Je mehr architektonischen Kontext Sie liefern, desto besser ist der generierte Code.
Muster 3: ein polymorphes Komponenten statt nahezu identischer Kopien
Ziel von wiederverwendbaren Komponenten ist es nicht, mehr davon zu haben, sondern Duplikate zu vermeiden. KI-Systeme erzeugen häufig solche Komponentenfamilien:
Button.tsx
LinkButton.tsx
IconButton.tsx
Sie teilen fast alle ihre Styling-Einstellungen. Was sich unterscheidet, ist das Element, das sie darstellen: manchmal ein echter <button>, manchmal ein Anchor-Element, manchmal ein Router-Link oder nur eine Icon-Kontrolle. Das polymorphe Muster ersetzt die ganze Familie durch ein einzelnes Komponente, das je nach Situation als beliebiges Element dargestellt werden kann.
Die duplizierte Version
Ein typisches generiertes Paar sieht so aus:
// Button.tsx
export function Button({ children }) {
return (
<button className="btn">
{children}
</button>
)
}
// LinkButton.tsx
export function LinkButton({ href, children }) {
return (
<a href={href} className="btn">
{children}
</a>
)
}
Die Styles werden nur dann wiederholt, weil sich das zugrundeliegende HTML-Element geändert hat.
Ein polymorphes Button in TypeScript
Die eingegebene Version akzeptiert ein optionales as-Attribut, dessen Typ ein generischer ElementType ist und standardmäßig auf "button" gesetzt ist. Die übrigen Attribute stammen vom jeweiligen Element oder Komponente, auf das as verweist, wobei ComponentPropsWithoutRef verwendet wird; as selbst wird weggelassen, um Kollisionen zu vermeiden.
type ButtonProps<E extends ElementType = "button"> = {
as?: E
children: ReactNode
variant?: "primary" | "outline"
} & Omit<React.ComponentPropsWithoutRef<E>, "as">
const Button = <E extends ElementType = "button">({
as,
children,
variant = "primary",
...rest
}: ButtonProps<E>) => {
const Component = as || "button"
return (
<Component {...rest}>
{children}
</Component>
)
}
Weil die Eigentypen dem Schema E folgen, macht das Übergeben von as="a" die href-Eigenschaft gültig, und das Übergeben eines Router-Link-Objekts macht dessen to-Eigenschaft gültig – alles mit Typüberprüfung. Das Beispiel ist vereinfacht dargestellt: variant wird akzeptiert, aber noch nicht angewendet; eine vollständige Version würde es auf einen Klassennamen abbilden und ggf. vorhandene className-Werte zusammenführen. Es ist außerdem sinnvoll, bei der Darstellung eines nativen Buttons standardmäßig type="button" zu verwenden, damit dieser nicht versehentlich ein umgebendes Formular absendet.
Dieser gleiche Komponententyp deckt nun alle Fälle ab:
<Button>
Save
</Button>
<Button
variant="outline"
>
Cancel
</Button>
<Button
as="a"
href="/pricing"
>
Pricing
</Button>
<Button
as={Link}
to="/dashboard"
>
Dashboard
</Button>
<Button
as="button"
onClick={handleDelete}
>
Delete
</Button>
Speichern, Abbrechen, ein Preislink, eine Dashboard-Route sowie eine Löschaktion teilen alle dasselbe Design; nur das dargestellte Element oder das Verhalten ändert sich.
Wann man darauf zurückgreifen sollte
Ein einfacher Test: Wenn die Benutzeroberfläche unverändert bleibt, aber das zugrunde liegende Element sich ändert, ist der Komponente ein guter Kandidat für Polymorphie. Häufige Beispiele:
- Buttons, die manchmal als Links fungieren
- Typografie-Komponenten, die
h1,h2,poderspandarstellen - Karten, die manchmal ein
articleund manchmal ein klickbarer Link sind - Layout-Container, die als
section,divodermaindargestellt werden
Das ist keine spezielle Präferenz. Komponentenbibliotheken wie Radix UI, Chakra UI und Flexy UI stützen sich stark auf dieselbe Idee, um flexible Design-Systeme zu erstellen.
Aufforderung dazu
Entwickler neigen dazu, für jede kleine Abweichung einen neuen Komponenten zu erstellen, daher geben Sie ihnen das Muster mit Namen vor: „Schreiben Sie eine polymorphe Button-Komponente, die ein as-Attribut entgegennimmt und einen nativen button, ein a-Tag oder einen React Router Link darstellen kann; handhaben Sie Varianten und Größen innerhalb dieser einzigen Komponente, anstatt neue zu erstellen.“ Dadurch entstehen weniger Komponenten, weniger Duplikate und ein kohärenteres Design-System.
Muster 4: Zusammenfassen von verwandtem Zustand mit einem Reducer
Dies ist vermutlich das Muster, das Entwickler am häufigsten übersehen. Ihre Standardpraxis ist es, für jeden Wert einen separaten useState() zu verwenden, was für einfache Komponenten in Ordnung ist. Wenn sich jedoch eine Funktion weiterentwickelt, muss man mit fünf oder sechs miteinander verbundenen Zuständen umgehen:
const [loading, setLoading] = useState(false);
const [products, setProducts] = useState([]);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState("latest");
Sobald Paginierung, Filtern, Sortieren, Laden und Fehlerbehandlung alle im Spiel sind, muss eine Benutzeraktion in der Regel mehrere dieser Funktionen gleichzeitig aktualisieren, wodurch die Komponente schwer verständlich wird.
Koordinierte Setter
So sieht ein Fetch-Vorgang mit getrennten Zuständen aus:
setLoading(true)
try {
const data = await fetchProducts()
setProducts(data)
setError(null)
} catch {
setError("Something went wrong")
} finally {
setLoading(false)
}
Mehrere Setter werden für ein einzelnes logisches Ereignis gleichzeitig ausgelöst. Diese Gruppierung ist das klassische Zeichen dafür, dass ein Reducer die Logik besser darstellen würde.
Übergänge als Aktionen modellieren
Mit einem Reducer beschreiben Sie die Struktur des Zustands einmal und listen Sie die möglichen Übergänge auf. Die Syntax wird vertraut erscheinen, wenn Sie bereits Redux verwendet haben:
const initialState = {
loading: false,
products: [],
error: null,
};
function reducer(state, action) {
switch (action.type) {
case "FETCH_START":
return {
...state,
loading: true,
};
case "FETCH_SUCCESS":
return {
loading: false,
products: action.payload,
error: null,
};
case "FETCH_ERROR":
return {
...state,
loading: false,
error: action.payload,
};
default:
return state;
}
}
FETCH_START setzt das Ladeflag, FETCH_SUCCESS speichert die Produkte und löscht sowohl das Lade- als auch das Fehlerrouting aus, während FETCH_ERROR den Fehler aufzeichnet. Im Erfolgsfall wird ein vollständig neues Objekt zurückgegeben; fügen Sie später Felder wie page oder sortBy zum State hinzu, vergessen Sie nicht, auch dort ...state zu verwenden – andernfalls werden diese Werte weggelassen.
Anschließend sendet das Komponente Aktionen anstatt Setter-Funktionen aufzurufen:
const [state, dispatch] = useReducer(
reducer,
initialState
)
dispatch({ type: "FETCH_START" })
dispatch({
type: "FETCH_SUCCESS",
payload: products,
})
Jede Aktualisierung fließt nun durch eine einzige Funktion. Um zu verstehen, wie die Funktionalität funktioniert, liest man den Reducer anstelle davon, nach setState-Aufrufen in der Komponente zu suchen. Da der Reducer eine reine Funktion ist, lässt er sich leicht unit testen.
Wann ein Reducer geeignet ist
Reducer eignen sich nicht für jede Komponente. Sie sind sinnvoll, wenn:
- mehrere Zustandsänderungen gleichzeitig;
- die Funktion hat viele unterschiedliche Aktionen;
- die Zustandsübergänge werden schwer nachvollziehbar;
- die Funktion ist ein Warenkorb, ein mehrstufiger Assistent, ein Anmeldeprozess, ein großes Formular oder eine überfüllte Kontrollleiste.
Für ein Modalfenster oder einen Schalter bleibt die einfache Verwendung von useState() die bessere Wahl.
Aufforderung dazu
Ohne Anleitung wird ein Agent fast immer eine Reihe von useState()-Aufrufen erzeugen. Fordern Sie die gewünschte Struktur an: „Implementieren Sie dies mit useReducer: definieren Sie benannte Aktionstypen, platzieren Sie jede Zustandsänderung innerhalb der Reducer-Funktion und lassen Sie das Komponente lediglich rendern und Nachrichten versenden.“ Je größer die Funktion ist, desto mehr bringt dies Vorteile.
Kernpunkte
- KI-Agenten erzeugen zuverlässig funktionierenden React-Code, greifen aber standardmäßig auf monolithische Komponenten, inline-Logik, nahezu identische Varianten sowie verstreuten Zustand zurück.
- Vier Muster beheben diese Standardlösungen: Abschnittskomposition, benutzerdefinierte Hooks, polymorphe Komponenten und Reduzierer.
- Jedes Muster hat einen klaren Auslöser; wenden Sie es an, wenn dieser Auslöser auftritt, und nicht aus Reflex.
- Die Benennung des Musters sowie der Elemente, die Sie in Ihrer Anfrage erwarten, ist der einfachste Weg, um von einem Agenten eine bessere Architektur zu erhalten.
- Das Erstellen von Code wird jedes Jahr einfacher; die Entscheidung darüber, wie er strukturiert werden soll, bleibt jedoch die Aufgabe des Entwicklers – und das Wissen um diese Muster ermöglicht es ihm, dies zu tun.