Feature-Based React Architecture

Robin Wieruch

In a server-driven React world with React Server Components and Server Actions, the UI seems closer to the database than ever before. While React Server Components (RSC) allow us to read data in the UI from the database, Server Actions enable us to write data back to the database.

In reality, the UI is not really closer to the database, there will always be the layers (e.g. domain layer, data access layer) in a larger application in between, but it feels like it.

When building full-stack React applications, we operate across both ends of the tech stack, giving us a unique perspective on how the database structure influences React components.

In this article, we’ll explore how database relationships shape component design and understand why React component composition is ideal solution to bridge these two worlds.

TODO: architecturereact-folder-structure/

Nested Relations in React Components

In a typical application, we will have a database with multiple tables that are related to each other. For example, a blog application might have a users table, a posts table, and a comments table. The posts table might have a foreign key to the users table, and the comments table might have foreign keys to both the users and posts tables.

Let’s take the posts and comments relationship without taking the user table into consideration, for the sake of simplicity, and see how it will influence our components:

ts
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])
  postId    Int
}

In a React component structure, we might have a Post component that renders a post and its comments. The Post component, as a Server Component, might look something like this, where we fetch the post and its comments from the database:

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>
  );
}

However, in order to keep our components focused, we might want to split the Post component into two components: a Post component that renders the post itself, and a Comments component that renders the comments. We focus each component on a single feature and therefore can also enforce a clean feature-based:

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>
  );
}

Splitting up components into smaller components is generally a best practice which comes with many advantages. Here we want to focus on the feature architecture and how it simplifies each file in its own feature folder. Next let’s check the getPost function that fetches the post and its comments from the database:

ts
const getPost = async (postId: string) => {
  const post = await prisma.post.findUnique({
    where: { id: postId },
    include: { comments: true },
  });

  return post;
}

Here again we want to keep the data fetching functions focused on their domain, so we might split the getPost function into two functions: a getPost function that fetches the post itself, and a getComments function that fetches the comments.

This way we don’t end up with permutations of nested relations in our data fetching functions (e.g. getPostWithComments, getPostWithAuthor) in a growing codebase. Let’s start with the focused getPost function:

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;
}

And here is the focused getComments function which sits in its own feature folder:

src/features/comment/queries/get-comments.ts
const getComments = async (postId: string) => {
  const comments = await prisma.comment.findMany({
    where: { postId },
  });

  return comments;
}

By not mixing features in components and data fetching functions, we will not have the problem of endless permutations of nested relations in our components and data fetching functions. In other words, we will not end up having functions being called getPostWithComments or getPostWithAuthor.

Never Miss an Article

Join 50,000+ developers getting weekly insights on full-stack engineering and AI.

AI Agentic UI Architecture React Next.js TypeScript Node.js Full-Stack Monorepos Product Engineering
Subscribe on Substack

High signal, low noise. Unsubscribe at any time.