React Server Components: The Mental Model You Need
Published
16 min read
Mar 8, 2025

Engineering

React Server Components: The Mental Model You Need

ReactNext.jsRSCPerformance

Overview

RSCs aren't just an optimization — they're a completely new way to think about data fetching and rendering. Here's the mental model that finally made it click.

ReactNext.jsRSCPerformance
Jordan Mercer

Jordan Mercer

Author
16 min read
Reading Time

React Server Components: The Mental Model You Need

Why RSCs Broke My Brain (And Then Fixed It)

When I first heard about React Server Components, I was confused. "Components that run on the server? Isn't that just... server-side rendering?"

No. It's fundamentally different. And once the mental model clicks, you'll wonder how you ever built apps without them.

React Server Components architecture diagramReact Server Components architecture diagram

The Old Mental Model (Client Components Only)

In traditional React:

jsx
// Everything runs on the client
function Dashboard() {
  const [user, setUser] = useState(null)
  const [posts, setPosts] = useState([])
  
  useEffect(() => {
    fetch('/api/user').then(setUser)
    fetch('/api/posts').then(setPosts)
  }, [])
  
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
    </div>
  )
}

Problems:

  • Waterfall: Fetch user → render → fetch posts
  • Bundle includes data-fetching logic
  • Client does all the work
  • No direct database access

The New Mental Model (Server + Client)

In RSC:

jsx
// Server Component (no 'use client' directive)
async function Dashboard() {
  // Direct database access! No API endpoint needed
  const user = await db.user.findUnique({ where: { id: session.userId } })
  const posts = await db.post.findMany({ where: { authorId: user.id } })
  
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
    </div>
  )
}

// Client Component ('use client' at top)
'use client'
function PostList({ posts }) {
  const [sortOrder, setSortOrder] = useState('desc')
  
  // Interactive but already has data from server
  const sortedPosts = sortPosts(posts, sortOrder)
  
  return (
    <>
      <button onClick={() => setSortOrder('asc')}>Sort Asc</button>
      {sortedPosts.map(post => <Post key={post.id} post={post} />)}
    </>
  )
}

Server vs client component comparisonServer vs client component comparison

The Three Component Types You Actually Need

1. Async Server Components (Most of your app)

jsx
// ✅ Use for: Data fetching, database access, heavy computations
async function ProductPage({ params }) {
  const product = await db.product.findUnique({
    where: { slug: params.slug }
  })
  
  return (
    <div>
      <h1>{product.name}</h1>
      <Price product={product} />
      <AddToCart productId={product.id} />
    </div>
  )
}

2. Client Components (Interactivity)

jsx
// ✅ Use for: useState, useEffect, event handlers, browser APIs
'use client'

function AddToCart({ productId }) {
  const [quantity, setQuantity] = useState(1)
  
  return (
    <div>
      <input 
        type="number" 
        value={quantity} 
        onChange={e => setQuantity(e.target.value)}
      />
      <button onClick={() => addToCart(productId, quantity)}>
        Add to Cart
      </button>
    </div>
  )
}

3. Shared Components (Can be either)

jsx
// No 'use client' = defaults to server, but safe on client
function Price({ product }) {
  // Just rendering, no state or effects
  return <span className="price">${product.price}</span>
}

// Can be imported into server OR client components

The Data Fetching Revolution

Before RSC: The Waterfall

Client request → HTML (empty) → JS loads → API call 1 → API call 2 → Render

After RSC: Parallel Everything

Server request → Fetch all data in parallel → Render HTML + data → Client

Real performance impact on a typical dashboard:

Traditional (Client-side fetch): 1800ms to interactive RSC: 400ms to interactive

The Suspense Integration (Game Changer)

jsx
// Server Component with Suspense boundaries
import { Suspense } from 'react'

async function Dashboard() {
  return (
    <div>
      <Suspense fallback={<UserProfileSkeleton />}>
        {/* This fetches independently */}
        <UserProfile />
      </Suspense>
      
      <Suspense fallback={<PostsSkeleton />}>
        {/* This fetches independently */}
        <PostsFeed />
      </Suspense>
      
      <Suspense fallback={<AnalyticsSkeleton />}>
        {/* This too! */}
        <AnalyticsWidget />
      </Suspense>
    </div>
  )
}

// Each component fetches its own data
async function UserProfile() {
  const user = await db.user.findUnique(...)
  return <div>{user.name}</div>
}

async function PostsFeed() {
  const posts = await db.post.findMany(...)
  return <PostList posts={posts} />
}

Suspense streaming visualizationSuspense streaming visualization

Practical Patterns That Work

Pattern 1: The Layout Pattern

jsx
// app/layout.js (Server Component)
export default function Layout({ children }) {
  // Runs once per request, not per navigation
  const theme = await getThemeFromDatabase()
  
  return (
    <html data-theme={theme}>
      <body>{children}</body>
    </html>
  )
}

Pattern 2: The Client Boundary Pattern

jsx
// Keep client components small and at the leaves
function InteractiveSection() {
  return (
    <div>
      <h1>Static header (server)</h1>
      
      {/* Only this button is interactive */}
      <ClientSidebar>
        <ServerContent /> {/* This still runs on server! */}
      </ClientSidebar>
    </div>
  )
}

Pattern 3: The Context Bridge

jsx
// Server Component provides data
async function App() {
  const user = await getUser()
  
  return (
    <ClientProvider initialUser={user}>
      <ClientComponentThatNeedsUser />
    </ClientProvider>
  )
}

// Client Component provides context
'use client'
function ClientProvider({ children, initialUser }) {
  const [user] = useState(initialUser)
  
  return (
    <UserContext.Provider value={user}>
      {children}
    </UserContext.Provider>
  )
}

Common Pitfalls (And How to Avoid Them)

Pitfall 1: Putting 'use client' Too High

❌ Bad:

jsx
'use client' // Entire app becomes client component
export default function App() { ... }

✅ Good:

jsx
// No 'use client' here (defaults to server)
import ClientButton from './ClientButton'

export default function App() {
  return (
    <div>
      <h1>Server rendered title</h1>
      <ClientButton /> {/* Only this is interactive */}
    </div>
  )
}

Pitfall 2: Fetching in Client Components

❌ Bad:

jsx
'use client'
function Posts() {
  const [posts, setPosts] = useState([])
  
  useEffect(() => {
    fetch('/api/posts').then(setPosts) // Waterfall + extra API
  }, [])
}

✅ Good:

jsx
async function Posts() {
  const posts = await db.post.findMany() // Direct + parallel
  return <ClientPostsList posts={posts} />
}

The Migration Strategy

Step 1: Convert data-fetching pages to Server Components Step 2: Move interactive parts into client components Step 3: Remove API endpoints that are only used by your own app Step 4: Add Suspense boundaries Step 5: Measure the performance improvement

Real Results from Production

E-commerce product page:

  • Before: 2.4s Time to Interactive
  • After: 0.6s Time to Interactive
  • Database queries: 5 → 1 (parallelized)

Content-heavy blog:

  • Before: 1.8s First Contentful Paint
  • After: 0.3s First Contentful Paint
  • JavaScript bundle: 450KB → 180KB

The Future Is Server Components

RSCs aren't just an optimization — they're a paradigm shift. They let you:

  • Write SQL directly in components
  • Eliminate entire API layers
  • Stream HTML as data loads
  • Keep interactive parts client-side

The mental model is simple: Server for data, client for interactivity. Everything else is implementation detail.

Once you build with RSCs, traditional client-only React feels like programming with one hand tied behind your back.

Jordan Mercer

Written by

Jordan Mercer

Let's work together

Start your project.