Mit React Server Components (RSC) können Daten direkt aus der Datenbank in das User Interface integriert werden, während Server Actions die Möglichkeit bieten, Daten zurück in die Datenbank zu schreiben. Während diese Funktionen in kleinen Anwendungen oft direkt nebeneinander existieren, können sie in größeren Projekten schnell zu einer komplexen, ungewollten Verschachtelung von vertikalen Features führen. Wir zeigen, wie eine featurebasierte Architektur in React aufgebaut werden kann, um skalierbare und wartbare Anwendungen zu erstellen. In einer solchen Architektur werden Features so weit wie möglich voneinander entkoppelt, sodass sich jede Komponente und ihre Datenzugriffslogik auf ihre spezifische Domäne konzentrieren kann.
Featurebasierte React-Komponenten
In einer typischen Anwendung haben wir eine Datenbank mit mehreren untereinander verknüpften Tabellen. Zum Beispiel enthält eine Bloganwendung Tabellen für User, Post und Comment. Die Tabelle Post enthält einen Foreign Key auf die Tabelle User, während die Tabelle Comment sowohl einen Foreign Key auf User als auch auf Post enthält.
Der Einfachheit halber konzentrieren wir uns auf die Beziehung zwischen den Tabellen Post und Comment und lassen die Tabelle User außer Acht [1]. In Listing 1 ist ein Prisma-Schema für die Datenbankmodelle zu sehen.
Listing 1
// my-blog/prisma/schema.prisma
…
model Post {
id String @id @default(cuid())
title String
content String
comments Comment[]
}
model Comment {
id String @id @default(cuid())
content String
post Post @relation(fields: [postId], references: [id])
postIdString
}
In einer React-Komponentenstruktur könnten wir eine Post-Komponente haben, die einen Post zusammen mit seinen Kommentaren anzeigt. Als Serverkomponente könnte die Post-Komponente wie in Listing 2 aussehen, wobei sowohl der Post als auch die zugehörigen Kommentare mit einem SQL Join gemeinsam aus der Datenbank abgerufen werden.
Listing 2
// my-blog/src/features/post/components/post.tsx
import { getPost } from '@/features/post/queries/get-post';
const Post = async ({ postId }: { postId: string }) => {
const post = await getPost(postId);
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
<ul>
{post.comments.map((comment) => (
<li key={comment.id}>{comment.content}</li>
))}
</ul>
</div>
);
}
export default Post;
Um unsere Komponenten zu optimieren, könnten wir die Post-Komponente in zwei separate Komponenten aufteilen: eine Post-Komponente, die den Post selbst darstellt, und eine Comments-Komponente, die die Kommentare anzeigt. Der Code in Listing 3 fokussiert jede Komponente auf ein einzelnes Feature, um eine saubere, featurebasierte Architektur zu schaffen.
Listing 3
// my-blog/src/features/post/components/post.tsx
import { Comments } from '@/features/comment/components/comments';
import { getPost } from '@/features/post/queries/get-post';
const Post = async ({ postId }: { postId: string }) => {
const post = await getPost(postId);
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Comments comments={post.comments} />
</div>
);
}
Die Aufteilung von Komponenten in kleinere, spezialisierte Einheiten ist eine bewährte Praxis, die zahlreiche Vorteile bietet. In diesem Fall liegt der Schwerpunkt auf der Featurearchitektur, die durch die Entkopplung von Features und die Platzierung jeder Datei in einem eigenen Featureordner zu einer klareren und übersichtlicheren Struktur führt [2].
LUST AUF NOCH MEHR REACT?
Entdecke Workshops vom 17. - 20. März 2025
Featurebasierte Datenabfrage
Lassen Sie uns nun betrachten, wie diese featurebasierte Architektur auf Datenabruffunktionen in React angewendet werden kann. In einem einfachen Ansatz würde beispielsweise die Funktion getPost sowohl den Post als auch die zugehörigen Kommentare in einer einzigen Anfrage aus der Datenbank abrufen. Das könnte wie in Listing 4 bei der Nutzung von Prisma als ORM implementiert werden.
Listing 4
// my-blog/src/features/post/queries/get-post.ts
..
const getPost = async (postId: string) => {
const post = await prisma.post.findUnique({
where: { id: postId },
include: { comments: true },
});
return post;
}
Auch hier wollen wir die Datenabfragefunktionen auf ihre jeweilige Domäne konzentrieren. So könnten wir die Funktion getPost in zwei separate Funktionen aufteilen: eine Funktion getPost, die nur die Posts selbst abfragt, und eine Funktion getComments, die nur die Kommentare abfragt.
Auf diese Weise vermeiden wir komplexe Kombinationen von verschachtelten Beziehungen (z. B. getPostWithComments oder getPostWithAuthor) in unseren Datenabfragefunktionen bei einer wachsenden Codebasis.
Anmerkung: Abfragen mit SQL Joins werden sicherlich Teil unserer größeren Anwendung sein – in einigen Fällen sind sie notwendig, um die Performance auf komplexen Seiten zu verbessern. Es bleibt immer ein Performance-Trade-off, die Abfragefunktionen nach Möglichkeit auf einen einzigen Zweck zu beschränken, leichtgewichtig und deskriptiv zu halten.
In Listing 5 sehen wir nun, wie die Funktion getPost implementiert werden kann. Und Listing 6 enthält die getComments-Funktion, die sich in einem eigenen Featureordner befindet.
Listing 5
// src/features/post/queries/get-post.ts
...
const getPost = async (postId: string) => {
const post = await prisma.post.findUnique({
where: { id: postId },
// include: { comments: true },
});
return post;
}
Listing 6
// src/features/comment/queries/get-comments.ts
const getComments = async (postId: string) => {
const comments = await prisma.comment.findMany({
where: { postId },
});
return comments;
}
Durch die Trennung von Komponenten- und Datenabfragefunktionen vermeiden wir zwar das Problem endloser Variationen verschachtelter Beziehungen in unseren Datenabfragefunktionen, allerdings hat diese Trennung den Nachteil, dass wir nun zwei separate, spezialisierte Abfragen benötigen, anstatt einer einzigen (Listing 7). Hier kommt der erwähnte Trade-off zwischen Performance und Maintainability (featurespezialisierte Funktionen, nicht überladene Funktionen, deskriptive Funktionen) zur Geltung.
Listing 7
//my-blog/src/features/post/components/post.tsx
import { getPost } from '@/features/post/queries/get-post';
import { getComments } from '@/features/comment/queries/get-comments';
import { Comments } from '@/features/comment/components/comments';
const Post = async ({ postId }: { postId: string }) => {
const post = await getPost(postId);
const comments = await getComments(postId);
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Comments comments={comments} />
</div>
);
}
export default Post;
Um die Post-Komponente noch weiter von der Comments-Komponente zu entkoppeln, können wir die Datenabfrage direkt in der Comments-Komponente initiieren. Dadurch muss die Post-Komponente keine Informationen über die Kommentare verwalten und übergibt lediglich die postId. Listing 8 zeigt den aktuellen Zustand der Comments-Komponente und Listing 9 zeigt die Post-Komponente, die nun die postId übergibt.
Listing 8
// my-blog/src/features/comment/components/comments.tsx
import { getComments } from '@/features/comment/queries/get-comments';
const Comments = async ({ postId }: { postId: string }) => {
const comments = await getComments(postId);
return (
<ul>
{comments.map((comment) => (
<li key={comment.id}>{comment.content}</li>
))}
</ul>
);
}
export default Comments;
Listing 9
//my-blog/src/features/post/components/post.tsx
import { Comments } from '@/features/comment/components/comments';
import { getPost } from '@/features/post/queries/get-post';
const Post = async ({ postId }: { postId: string }) => {
const post = await getPost(postId);
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Comments postId={postId} />
</div>
);
}
export default Post;
Indem wir die Komponenten und ihre Datenabfragefunktionen klar auf ihre jeweilige Funktionalität fokussieren, schaffen wir eine skalierbare React-Anwendung, die sowohl wartungsfreundlich als auch erweiterbar ist.
Funktionsbasierte Architektur in React
Die aktuelle Entkopplung hat den Nachteil, dass wir eine sequenzielle Datenabfrage in den Komponenten durchführen. Das bedeutet, dass die Post-Komponente zuerst den Post holt und erst anschließend nach dem Wasserfallprinzip die Comment-Komponente die Kommentare lädt.
Mit anderen Worten, wir haben sequenzielle Datenabrufe in den Komponenten anstatt gleichzeitiger Abfragen. Das ist aus Performancesicht nicht ideal, insbesondere wenn die Kommentare nur die ID des Posts und nicht den gesamten Post selbst benötigen.
Wir hätten dieses Problem schon früher durch gleichzeitige Datenabrufe in der Post-Komponente lösen können, aber der Fokus lag zunächst darauf, die Funktionen so weit wie möglich zu entkoppeln. Jetzt können wir diese Optimierung durchführen. Wir verwenden die Component Composition in React und wenden diese auf die Parent-Komponente der Post-Komponente (in unserem Fall die PostPage-Komponente) an (Listing 10).
Listing 10
// my-blog/src/app/page.tsx
import { Post } from '@/features/post/components/post';
import { getPost } from '@/features/post/queries/get-post';
import { Comments } from '@/features/comment/components/comments';
import { getComments } from '@/features/comment/queries/get-comments';
const PostPage = async ({ postId }: { postId: string }) => {
const post = await getPost(postId);
const comments = await getComments(postId);
return (
<Post
post={post}
comments={<Comments comments={comments} />}
/>
);
}
export default PostPage;
Im Fall der comments könnten wir in React auch Children verwenden. Unsere Lösung hat den Vorteil, dass wir die Props beschreibend halten und einen Namen wählen können, der klar kommuniziert, worum es sich handelt.
Der Code in Listing 11 zeigt, wie wir von einer sequenziellen Datenabfrage zu einer gleichzeitigen Datenabfrage in der PostPage-Komponente wechseln. Der Komponente Post werden dann post und comments in den Props übergeben (Listing 12). Und zuletzt wird die Komponente Comments per Props mit den comments gefüllt (Listing 13).
Listing 11
// my-blog/src/app/page.tsx
…
const PostPage = async ({ postId }: { postId: string }) => {
const postPromise = getPost(postId);
const commentsPromise = getComments(postId);
const [post, comments] = await Promise.all([
postPromise,
commentsPromise,
]);
return (
<Post
post={post}
comments={<Comments comments={comments} />}
/>
);
}
export default PostPage;
Listing 12
// my-blog/src/features/post/components/post.tsx
type PostProps = {
post: Post;
comments: ReactNode;
};
const Post = ({ post, comments }: PostProps) => {
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
{comments}
</div>
);
}
export default Post;
Listing 13
// my-blog/src/features/comment/components/comments.tsx
type CommentsProps = {
comments: Comment[];
};
const Comments = ({ comments }: CommentsProps) => {
return (
<ul>
{comments.map((comment) => (
<li key={comment.id}>{comment.content}</li>
))}
</ul>
);
}
export default Comments;
Durch die Kombination einer featurebasierten Architektur mit der Möglichkeit, Daten innerhalb von Komponenten gleichzeitig abzurufen, vereinen wir das Beste aus beiden Welten: einfache Wartbarkeit und Erweiterbarkeit bei optimaler Performance. Besonders vorteilhaft ist die Flexibilität, einzelne Komponenten wie Post oder Comments unabhängig voneinander zu einer Clientkomponente zu machen, ohne dass die andere darunter leidet.
In kleinen React-Projekten mag diese Struktur nicht zwingend notwendig sein, in größeren Anwendungen ist es jedoch entscheidend, Komponenten und ihre zugehörige Logik (z. B. Datenabruf) auf ihre spezifische Domäne zu fokussieren. Dieser Ansatz trägt dazu bei, dass Komponenten innerhalb eines bestimmten Features nicht mit unnötiger oder unzusammenhängender Logik überlastet werden. Anstatt alle Funktionen in einer Komponente zu vereinen (siehe getPostWithComments oder getPostWithAuthor), konzentriert sich jede Komponente auf ihre spezifische Aufgabe. Dadurch bleibt der Code übersichtlich, leichter wartbar und erweiterbar, ohne dass unterschiedliche Verantwortlichkeiten vermischt werden. Das führt zu einer klareren Struktur und verhindert, dass einzelne Komponenten zu komplex oder unübersichtlich werden.
Natürlich gibt es Ausnahmen, wie in diesem Artikel erwähnt. In komplexeren Szenarien, in denen Abfragen mit SQL Joins erforderlich sind, können diese die Performance erheblich verbessern. Es ist jedoch wichtig, das n+1-Problem zu vermeiden. Auf einer Seite wie der individuellen PostPage ist es sinnvoll, nur eine Abfrage für den Post und eine weitere für die Kommentare zu machen. Auf der PostsPage hingegen ist es nicht sinnvoll, für jeden Post eine separate Anfrage für Kommentare zu erstellen, da dies zu unnötigen Datenbankabfragen führen würde. Hier bieten sich Joins als Lösung an – aber nur, wenn es wirklich notwendig ist, alle Kommentare auf einmal abzurufen. In vielen Fällen kann es effizienter sein, diese nur bei Bedarf oder über ein Hidden Pane (versteckter Bereich) zu laden.
Links & Literatur
[1] https://codeberg.org/astrid/entwickler_react_feature_based_architecture
[2] https://www.robinwieruch.de/react-folder-structure
Stay tuned
Bei neuen Artikel & Eventupdates informiert werden: