The React Server Components Mental Model I Wish I Had Sooner
For a long time, React Server Components (RSCs) felt like magic to me. Magic is cool when you're watching a show, but terrible when you're trying to debug a production app.
Every time I tried to use them, I'd end up with a dreaded Error: Cannot use useState in a Server Component. Sound familiar?
The problem wasn't the technology. The problem was my mental model. I was still thinking about them as "React components that happen to run on the server."
They aren't.
The Mental Shift
Here's the analogy that finally made it click for me:
Think of Server Components as your backend API, and Client Components as your frontend app.
Imagine you're building a traditional app. You write a Node.js endpoint that talks to a database, formats the data, and sends JSON to the client. The client receives that JSON, puts it in state, and renders it.
With RSCs, you're doing the exact same thing, but instead of sending JSON over the wire, you're sending UI.
Server Components:
- Run exactly once per request.
- Have direct access to your database or filesystem.
- CANNOT have state (
useState), effects (useEffect), or interactivity (onClick). Why? Because they run on the server, not in the browser! There's no user to interact with them yet.
Client Components:
- Run in the browser (and are pre-rendered on the server).
- Have state, effects, and interactivity.
- Cannot securely access your database directly.
The Boundary
The most important concept to grasp is the boundary between the two.
When you add "use client" to the top of a file, you're drawing a line in the sand. You're saying, "Everything below this line needs to be shipped to the browser."
The beauty of the Next.js App Router is that it defaults to Server Components. You start on the server, fetching data right where it lives, and only cross that "use client" boundary when you absolutely need interactivity.
An Example
Let's look at a typical product page.
// app/products/[id]/page.tsx (Server Component)
import { getProduct } from '@/lib/db'
import AddToCartButton from './AddToCartButton'
export default async function ProductPage({ params }) {
// We're on the server. We can talk to the DB directly!
const product = await getProduct(params.id)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* We pass the data to a Client Component for interactivity */}
<AddToCartButton productId={product.id} price={product.price} />
</div>
)
}And the client component:
// app/products/[id]/AddToCartButton.tsx
"use client" // <-- The boundary
import { useState } from 'react'
export default function AddToCartButton({ productId, price }) {
const [isAdding, setIsAdding] = useState(false)
const handleAdd = async () => {
setIsAdding(true)
// call api...
setIsAdding(false)
}
return (
<button onClick={handleAdd} disabled={isAdding}>
{isAdding ? 'Adding...' : `Add to Cart - $${price}`}
</button>
)
}Notice how clean this is? The Server Component handles the heavy lifting of data fetching, and the Client Component only handles the specific piece of UI that needs to be interactive.
Stop Passing Props
One of the biggest mistakes I see (and made myself) is trying to pass complex objects or functions from a Server Component to a Client Component.
Remember: whatever crosses the "use client" boundary has to be serialized and sent over the network. You can't pass functions, and passing massive JSON objects defeats the purpose of keeping the heavy lifting on the server.
Keep the props crossing the boundary as small and simple as possible.
Once I started treating Server Components like an API that returns UI instead of JSON, everything got easier. The errors went away, and my bundle sizes plummeted.
If you're struggling with RSCs, try reframing how you think about them. It makes all the difference.