Articles

React Server Components: How We Got Here

Kent C. Dodds
Kent C. Dodds

The web has come a long way since the early days of Multi-Page Applications (MPAs). What started as simple server-rendered HTML has evolved through several architectural paradigms, each solving problems introduced by the previous approach. Today, React Server Components (RSCs) represent what might be the most significant leap forward yet.

Let me take you through this complete journey and show you why each step mattered.

The Early Web: Multi-Page Applications

In the beginning, there were Multi-Page Applications. Each user interaction resulted in a full page reload, with the server generating complete HTML documents.

Here's what a simple counter app looked like in those days:


app.get('/', async (c) => {
const count = await db.getCount()
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MPA Counter</title>
</head>
<body>
<h1>Count: ${count}</h1>
<form action="/update-count" method="POST">
<button type="submit" name="change" value="-1">Decrement</button>
<button type="submit" name="change" value="1">Increment</button>
</form>
</body>
</html>
`
return c.html(html)
})
app.post('/update-count', async (c) => {
const formData = await c.req.formData()
const change = Number(formData.get('change'))
await db.changeCount(change)
return c.redirect('/')
})

The Good:

  • Simple and straightforward
  • Progressive enhancement worked out of the box
  • No JavaScript required
  • Great for SEO and accessibility

The Problems:

  • Poor user experience with full page reloads
  • High server load generating entire pages
  • Difficult to maintain complex state
  • No way to provide immediate feedback

The POST → Redirect → GET pattern was a common solution to prevent duplicate form submissions, but it still required full page reloads.

The React Revolution: Single-Page Applications

React introduced a paradigm shift. Instead of server-rendered HTML, we moved to client-side JavaScript that managed the entire application state.

Here's how our counter app evolved:


export function Counter() {
const [state, dispatch] = useReducer(countReducer, {
count: null,
loading: true,
error: null,
})
useEffect(() => {
async function fetchCount() {
dispatch({ type: 'FETCH_START' })
try {
const response = await fetch('/count')
if (!response.ok) throw new Error('Failed to fetch count')
const data = await response.json()
dispatch({ type: 'FETCH_SUCCESS', payload: data.count })
} catch (err) {
dispatch({ type: 'FETCH_ERROR', payload: err.message })
}
}
fetchCount()
}, [])
async function updateCount(change) {
dispatch({ type: 'UPDATE_START' })
try {
const response = await fetch('/update-count', {
method: 'POST',
body: JSON.stringify({ change }),
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) throw new Error('Failed to update count')
const { count } = await response.json()
dispatch({ type: 'UPDATE_SUCCESS', payload: count })
} catch (err) {
dispatch({ type: 'UPDATE_ERROR', payload: err.message })
}
}
if (state.loading && state.count === null) return <div>Loading...</div>
if (state.error) return <div>Error: {state.error}</div>
return (
<div>
<h1>Count: {state.count}</h1>
<div style={{ opacity: state.loading ? 0.6 : 1 }}>
<button onClick={() => updateCount(-1)}>Decrement</button>
<button onClick={() => updateCount(1)}>Increment</button>
</div>
</div>
)
}

The Good:

  • Smooth, responsive user experience
  • No full page reloads
  • Rich interactive interfaces
  • Better state management

The Problems:

  • Lost progressive enhancement
  • Complex network state management
  • Large JavaScript bundles
  • Poor initial page load performance
  • SEO challenges

Notice how much more code we now need just to handle network requests! We have to manage loading states, error states, and manually wire up all the data fetching logic.

The Framework Solution: Progressive Enhancement SPAs

Frameworks like Remix emerged to solve these problems by bringing back progressive enhancement while maintaining the benefits of SPAs. They introduced concepts like loaders and actions to handle data fetching and mutations.


export async function loader() {
const db = await import('../db.js')
return { count: await db.getCount() }
}
export async function action({ request }) {
const db = await import('../db.js')
const formData = await request.formData()
const change = Number(formData.get('change'))
await db.changeCount(change)
return { success: true }
}
export function Counter() {
const data = useLoaderData()
const fetcher = useFetcher()
return (
<div>
<fetcher.Form method="POST" action="/counter">
<h1>Count: {data.count}</h1>
<div style={{ opacity: fetcher.isPending ? 0.6 : 1 }}>
<button type="submit" name="change" value="-1">
Decrement
</button>
<button type="submit" name="change" value="1">
Increment
</button>
</div>
</fetcher.Form>
</div>
)
}

The Good:

  • Progressive enhancement restored
  • Much less network management code
  • Better performance with server-side rendering
  • Forms work without JavaScript

The Problems:

  • Route-based composition (mixing routes and components)
  • Still need to manually wire up data fetching
  • Framework-specific patterns to learn
  • Limited flexibility in data composition

This approach significantly reduced the network management code, but it introduced a new problem: the composition boundary was now the route, not the component.

The Modern Solution: React Server Components

React Server Components represent a fundamental shift back to component-based composition while eliminating the need for custom network management code entirely.


'use server'
import * as db from '../db.js'
import { PendingDiv } from './pending-form.js'
export async function updateCount(formData) {
try {
const change = Number(formData.get('change'))
await db.changeCount(change)
return { status: 'success', message: 'Success!' }
} catch (error) {
return { status: 'error', message: error?.message || String(error) }
}
}
export async function Counter() {
const count = await db.getCount()
return (
<div>
<h1>Count: {count}</h1>
<form action={updateCount}>
<PendingDiv>
<button type="submit" name="change" value={-1}>
Decrement
</button>
<button type="submit" name="change" value={1}>
Increment
</button>
</PendingDiv>
</form>
</div>
)
}

And the client component for pending state:


'use client'
import { useFormStatus } from 'react-dom'
export function PendingDiv(props) {
const { pending } = useFormStatus()
return <div style={{ opacity: pending ? 0.6 : 1 }} {...props} />
}

The Good:

  • Component-based composition restored
  • No custom network management code needed
  • Progressive enhancement by default
  • Server and client code clearly separated
  • Optimal performance with server-side rendering
  • Forms work without JavaScript
  • Pending states handled automatically

The Problems:

  • New mental model to learn
  • Different boundaries between server and client
  • Some ecosystem tools still catching up

Why This Journey Matters

Each step in this evolution solved real problems while introducing new challenges that the next step would address:

  1. MPAs were simple but provided poor user experience
  2. SPAs improved UX but lost progressive enhancement and required complex network management
  3. PESPAs restored progressive enhancement but mixed compositional paradigms
  4. RSCs bring back component-based composition while eliminating network boilerplate

The key insight is that React Server Components aren't just another framework feature—they're a fundamental rethinking of how we compose applications. By moving the composition boundary back to the component level and eliminating the need for custom network management code, RSCs give us the best of all previous approaches:

  • The simplicity of MPAs
  • The interactivity of SPAs
  • The progressive enhancement of PESPAs
  • The composition model of React

The Network Vanishes

The most remarkable thing about React Server Components is how they make network management code disappear. In the early React days, we had to manually manage:

  • Loading states
  • Error states
  • Data fetching logic
  • Form submission handling
  • Optimistic updates
  • Cache invalidation

With RSCs, the framework handles all of this automatically. Server components can fetch data directly, and server actions handle mutations with built-in pending states and error handling.

This isn't just about reducing boilerplate—it's about eliminating entire categories of bugs and complexity that developers have been wrestling with for years.

Looking Forward

React Server Components represent what might be the most significant architectural advancement in web development since React itself. They solve the fundamental tension between server-side rendering and client-side interactivity by making the distinction explicit and manageable.

As the ecosystem continues to mature around RSCs, we're seeing a new generation of applications that combine the best aspects of all previous approaches. The network management code that once dominated our applications is finally vanishing, replaced by a simpler, more powerful model.

The journey from MPAs to RSCs shows us that web development is constantly evolving, with each step building on the lessons learned from previous approaches. React Server Components aren't just the latest trend—they're the natural evolution of web architecture, bringing us closer to the ideal of simple, performant, and maintainable web applications.


This article is based on the "React and the Vanishing Network" workshop, which provides hands-on experience with each of these architectural approaches. You can explore the complete code examples and see the evolution in action. You can also watch a talk version of this workshop from my talk at React Paris 2025

Get my free 7-part email course on React!

Delivered straight to your inbox.