Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Events
Videos
Audiobooks
Packt Hub
Free Learning
Arrow right icon
timer SALE ENDS IN
0 Days
:
00 Hours
:
00 Minutes
:
00 Seconds
React Design Patterns and Best Practices
React Design Patterns and Best Practices

React Design Patterns and Best Practices: Design, build, and deploy production-ready web applications by leveraging industry-best practices , Fifth Edition

Arrow left icon
Profile Icon Carlos Santana Roldán
Arrow right icon
Can$45.89 Can$50.99
eBook Aug 2026 666 pages 5th Edition
eBook
Can$45.89 Can$50.99
Paperback
Can$63.99
eBook + Subscription
Free Trial
Arrow left icon
Profile Icon Carlos Santana Roldán
Arrow right icon
Can$45.89 Can$50.99
eBook Aug 2026 666 pages 5th Edition
eBook
Can$45.89 Can$50.99
Paperback
Can$63.99
eBook + Subscription
Free Trial
eBook
Can$45.89 Can$50.99
Paperback
Can$63.99
eBook + Subscription
Free Trial

What do you get with eBook?

Product feature icon Instant access to your Digital eBook purchase
Product feature icon Download this book in EPUB and PDF formats
Product feature icon Access this title in our online reader with advanced features
Product feature icon DRM FREE - Read whenever, wherever and however you want
Product feature icon AI Assistant (beta) to help accelerate your learning
Modal Close icon
Payment Processing...
tick Completed

Billing Address

Table of content icon View table of contents Preview book icon Preview Book

React Design Patterns and Best Practices

1

Mastering React Server Components (RSC)

Hello, readers!

This book assumes that you already have experience with React and have built applications with it. You understand components, props, and state, but now you want to take your React skills to the next level. As the React ecosystem continues to evolve, mastering new patterns and features like React Server Components (RSC) becomes essential for building modern, performant applications.

In this first chapter, we will explore RSC, a transformative approach that's reshaping how we think about rendering in React applications. We'll dive into what makes RSC special, how they differ from traditional Client Components, and how they can dramatically improve your application's performance and user experience.

The chapter covers the following topics:

  • Introduction to RSC: Why they matter
  • Setting up RSC in Next.js 16
  • Writing optimized RSC
  • Differentiating server and client components
  • Efficient data fetching with RSC
  • What's new in React 19.2?

Download the code bundle and the PDF version of this book

Your purchase includes a DRM-free PDF copy of this book, the code bundle, and a range of exclusive benefits. To unlock everything, follow the Free benefits with your book section in the Preface.

Technical requirements

To follow this book, you need to have some experience in using the terminal to run a few Unix commands. Also, you need to install Node 24. You have two options: the first one is to download Node.js directly from the official website (https://nodejs.org), and the second option (recommended) is to install Node Version Manager (NVM) from https://github.com/nvm-sh/nvm.

Framework Support: Next.js currently provides the most complete, production-ready implementation of RSC, which is why it serves as the foundation for this book. Although the React team collaborates with various framework authors and alternatives like Remix are exploring integration, Next.js offers the most mature ecosystem available today. This makes it the definitive choice for exploring and deploying this new paradigm in real-world applications.

Introduction to RSC: why they matter

The way we render React applications has evolved significantly over the years. In the beginning, we had client-side rendering (CSR), where the entire application was shipped as JavaScript to the browser, which then handled all the rendering. This approach had downsides: slow initial loads, poor SEO, and performance issues on less powerful devices.

To address these limitations, server-side rendering (SSR) emerged, allowing React components to render on the server and send HTML to the client. This improved initial load times and SEO but still required shipping the entire JavaScript bundle to enable interactivity—a process called hydration. While SSR reduced initial load times, most real-world applications still need significant JavaScript for interactivity, meaning the hydration cost remains substantial.

RSC represent one approach in this evolution, alongside alternatives like Qwik's resumability and Astro's Islands architecture. They fundamentally change how we think about rendering by enabling components to render exclusively on the server, without sending their JavaScript to the client at all.

Why RSC improve performance

RSC offer several performance advantages:

  • Zero JavaScript footprint: Server Components run only on the server and don't ship any JavaScript to the client, reducing bundle sizes dramatically.
  • Direct database access: Server Components can directly access databases and backend resources without the need for API layers.
  • Reduced Waterfall requests: Instead of waiting for the client to load, execute JavaScript, then make API calls, Server Components can fetch data directly during rendering.
  • Improved time-to-interactive: With less JavaScript to parse and execute, applications become interactive faster.
  • Progressive enhancement: Server Components work alongside Client Components, allowing you to add interactivity only where needed.

Let's look at a simple comparison. Here's a traditional client component that fetches and displays user data:

// Traditional Client Component
'use client'
import { useState, useEffect } from 'react'
type User = {
  id: string
  name: string
  email: string
  createdAt: string
}
type Props = {
  userId: string
}
const UserProfile = ({ userId }: Props) => {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState<boolean>(true)
  useEffect(() => {
    const fetchUser = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`)
        const data: User = await response.json()
        setUser(data)
      } catch (error) {
        console.error('Failed to fetch user', error)
      } finally {
        setLoading(false)
      }
    }
    fetchUser()
  }, [userId])
  if (loading) {
    return <div>Loading user...</div>
  }
  if (!user) {
    return <div>User not found</div>
  }
  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Member since: {new Date(user.createdAt).toLocaleDateString()}</p>
    </div>
  )
}
export default UserProfile

Now, here's the same functionality with a Server Component:

// Server Component
type User = {
  id: string
  name: string
  email: string
  createdAt: string
}
type Props = {
  userId: string
}
async function UserProfile({ userId }: Props) {
  const user = await fetchUserDirectly(userId)
  if (!user) {
    return <div>User not found</div>
  }
  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Member since: {new Date(user.createdAt).toLocaleDateString()}</p>
    </div>
  )
}
// This function runs on the server only
async function fetchUserDirectly(userId: string): Promise<User | null> {
  // Replace with your actual DB logic
  return await db.users.findById(userId)
}
export default UserProfile

Notice how the Server Component is cleaner, with no useState or useEffect hooks, and can directly access data sources on the server. Loading and error states are handled declaratively through Suspense and Error Boundaries at the parent level.

How RSC reduces JavaScript bundle sizes

One of the most significant benefits of Server Components is their impact on bundle sizes. When you mark a component as a Server Component, none of its code is sent to the client—only the resulting HTML.

Consider a data visualization component that uses a heavy charting library like D3.js. If this component is client-rendered, the entire D3 library must be downloaded, parsed, and executed on the client. But as a Server Component, the chart is generated on the server, and only the resulting SVG/HTML is sent to the client. This approach works best for static visualizations; interactive features like tooltips or zoom still require Client Components. Also keep in mind that generating charts on every request adds server CPU overhead, so caching strategies become important at scale. Let's demonstrate the bundle size difference with a practical example:

// Client Component importing a large library
'use client'
import { useState } from 'react'
import * as d3 from 'd3' // 300KB+ of JavaScript
function DataChart({ data }) {
  const [activeIndex, setActiveIndex] = useState(null)
  // d3 rendering logic here
  return <div className="chart-container">...</div>
}

As a Server Component:

// Server Component
import * as d3 from 'd3' // Never sent to the client

export function DataChart({ data }: { data: { label: string; value: number }[] }) {
  const width = 400
  const height = 200

  const x = d3
    .scaleBand()
    .domain(data.map((d) => d.label))
    .range([0, width])
    .padding(0.1)

  const y = d3
    .scaleLinear()
    .domain([0, d3.max(data, (d) => d.value) ?? 0])
    .range([height, 0])

  return (
    <svg viewBox={`0 0 ${width} ${height}`} width={width} height={height}>
      {data.map((d) => (
        <rect
          key={d.label}
          x={x(d.label)}
          y={y(d.value)}
          width={x.bandwidth()}
          height={height - y(d.value)}
        />
      ))}
    </svg>
  )
} 

In the second example, D3 runs only on the server for chart calculations such as scales, ticks, and layout. None of that D3 code is shipped to the client. The browser receives only the final SVG markup rendered by React, which can significantly reduce bundle size and improve page-load performance.

We've established the fundamental principles for organizing RSC applications: defaulting to Server Components to maximize performance benefits, strategically minimizing client-side JavaScript through careful use of use client directives, maintaining clear architectural boundaries between server and client logic, and leveraging file system structure to create intuitive, maintainable component hierarchies.

Comparing RSC with SSR and Client Components

To understand RSC better, let's compare it with other rendering approaches:

Feature

Client Components

SSR

RSC

Rendering Location

Browser

Server, then Browser

Server

JavaScript Sent to Client

Full Component Code

Full Component Code

None

Interactivity

Full

Full (after hydration)

None (unless wrapped with Client Components)

Data Fetching

Client-side (useEffect)

Server for initial HTML, client for updates

Server-side

Bundle Size Impact

Largest

Large (requires hydration)

Smallest

Backend Access

Via APIs only

Via APIs or database

Direct

SEO

Poor (without SSR, depends on crawler)

Good

Good

Streaming SSR

Not applicable

Supported

Supported

Selective Hydration

Not applicable

Supported

Not needed

Table 1.1 – Client Components, SSR and RSC comparison

The key difference between SSR and RSC is that with SSR, we still send all the component JavaScript to the client for hydration. With RSC, server components remain on the server, sending only their output to the client.

This is not to say that client components are obsolete; they're essential for interactive UI elements. The real power comes from combining Server and Client Components strategically to get the best of both worlds.

This comprehensive comparison shows how RSC occupy a unique position in the rendering landscape, offering the SEO benefits of SSR while achieving the smallest possible bundle sizes by keeping component code entirely on the server. Now that we understand what makes RSC special and how they compare to other approaches, we're ready to explore how to implement them in practice.

Setting up RSC in Next.js 16+

Next.js uses the App Router as its React Server Components architecture. In an App Router project, files under app/ are Server Components by default unless you opt into client-side behavior with the use client directive. This makes Next.js one of the simplest ways to start working with RSCs. You can create a new project with:

npx create-next-app@latest my-rsc-app
cd my-rsc-app

During the setup, you'll be asked several questions. For RSC, make sure to:

  • Use the App Router (not the Pages Router)
  • Select your preferred styling solution
  • Choose whether to use TypeScript

Once the project is created, you'll have a structure that supports Server Components by default. Every component in the app directory is a Server Component unless specified otherwise.

The basic folder structure will look something like this:

my-rsc-app/
  - app/
     - layout.tsx
     - page.tsx
     - [...other routes]
  - components/
  - public/
  - next.config.ts
  - package.json

In Next.js 16, the app directory uses the App Router, which has built-in support for RSC. All components inside this directory are Server Components by default, and you don't need to explicitly mark them as such.

How RSC rendering works

A common misconception is that Server Components simply render HTML and send that HTML to the browser. That is not the full picture. React renders Server Components into a special data stream called the React Server Component Payload, also known as the Flight format. Next.js then uses that payload together with Client Components to produce the final UI and prerendered HTML where appropriate.

This distinction matters because RSC is not just SSR with components on the server. Server Components are a separate rendering model that lets the server resolve component trees, strip server-only code from the client bundle, and send a compact payload describing what the client should render.

use server: Server Functions and Server Actions

It is important not to confuse RSC with Server Actions:

  • RSC is the rendering model.
  • use server marks Server Functions.
  • Server Actions are a form workflow and mutation pattern built on top of server functions.

In practice, use server is used to define code that must run only on the server. You can place it inline inside an async function, or at the top of a file to mark exported functions as server-only. In Next.js documentation, these are described as server-side functions that can be invoked from forms or from client code through the framework's action mechanism.

Understanding use server and how it works

The use server directive is a core part of the RSC paradigm. It marks functions that should execute exclusively on the server, even if they're called from client components.

There are two ways to use the use server directive: At the function level:

'use client'
// This component is a Client Component
export default function Form() {
  async function handleSubmit(formData: FormData) {
    await submitForm(formData)
  }
  return <form action={handleSubmit}>...</form>
}
// But this function runs on the server
async function submitForm(formData) { 
  'use server'
  // Server-side validation and processing
  const data = Object.fromEntries(formData)
  await db.saveData(data)
  // ...
}

At the file level (affects all exported functions):

'use server'
// All functions in this file run on the server
export async function createPost(data) {
  await db.posts.create(data)
  revalidatePath('/posts')
}
export async function deletePost(id) {
  await db.posts.delete(id)
  revalidatePath('/posts')
}

When the client calls a server function, Next.js serializes the arguments, sends them to the server, executes the function, and returns the result to the client. This creates a secure bridge between client and server code without needing to build separate API endpoints.

Writing your first server component

Let's create a simple Server Component that fetches and displays blog posts:

// app/posts/page.tsx
// No 'use client' directive - Server Component by default
async function getPosts() {
  // In a real app, this would be a database query
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    cache: 'no-store' // Don't cache this data
  })
  if (!res.ok) {
    throw new Error('Failed to fetch posts')
  }
  return res.json()
}
export default async function PostsPage() {
  // Data fetching happens directly in the component
  const posts = await getPosts()
  return (
    <div className="posts-container">
      <h1>Recent Posts</h1>
      <div className="posts-grid">
        {posts.slice(0, 12).map(post => (
          <article key={post.id} className="post-card">
            <h2>{post.title}</h2>
            <p>{post.body.substring(0, 100)}...</p>
            <a href={`/posts/${post.id}`}>Read more</a>
          </article>
        ))}
      </div>
    </div>
  )
}

This Server Component:

  • Directly fetches data without useEffect or useState
  • Uses async/await syntax at the component level
  • Renders the posts on the server
  • Sends only the resulting HTML to the client

Debugging and troubleshooting RSC setup

When working with RSC, you might encounter some common issues:

  • Cannot use hooks in Server Components error:
    // This will cause an error
    function BrokenServerComponent() {
      // useState is not allowed in Server Components
      const [count, setCount] = useState(0)
      return <div>{count}</div>
    }

    Solution: Either move the component to the client or split it into client and server parts:

    'use client'
    // Now it's a Client Component and can use hooks
    function CounterComponent() {
      const [count, setCount] = useState(0)
      return <div>{count}</div>
    }
  • Event handlers are not supported in Server Components error:
    // This will cause an error
    function BrokenButtonComponent() {
      return <button onClick={() => alert('Clicked!')}>Click me</button>
    }

    Solution: Move interactive elements to Client Components:

    // Client component
    'use client'
    function InteractiveButton() {
      return <button onClick={() => alert('Clicked!')}>Click me</button>
    }
    // Server Component
    export default function Page() {
      return (
        <div>
          <h1>My Page</h1>
          <InteractiveButton />
        </div>
      )
    }
  • Importing Client Components in Server Components: This is supported! You can import a Client Component into a Server Component:
    // ServerComponent
    import ClientComponent from './ClientComponent'
    export default function ServerComponent() {
      return (
        <div>
          <h1>Server Component</h1>
          <ClientComponent /> {/* This works! */}
        </div>
      )
    }
    // ClientComponent
    'use client'
    export default function ClientComponent() {
      return <button onClick={() => alert('Clicked!')}>Click me</button>
    }
  • Handling data fetching errors: Use error boundaries and Next.js's error.js files:
    // app/posts/error.tsx
    'use client'
    import { useEffect } from 'react'
    export default function Error({ error, reset }) {
      useEffect(() => {
        // Log the error to an error reporting service
        console.error(error)
      }, [error])
      return (
        <div className="error-container">
          <h2>Something went wrong!</h2>
          <button onClick={() => reset()}>Try again</button>
        </div>
      )
    }

Debugging RSC requires a shift in how you track down issues. Because these components execute on the server, your console.log statements will output to your terminal or server logs, not the browser's developer tools.

To troubleshoot effectively, structure your debugging approach around these technical practices:

  • Server-side logging: Wrap asynchronous data fetches in try-catch blocks. Record error details with timestamps and context. For complex setups, move beyond plain text logs and implement structured JSON logging.
  • Isolate failures: Unhandled server errors will completely crash the component render. Use Next.js error boundaries (error.tsx files) to catch these exceptions at the segment level and display fallback UIs.
  • Node.js tooling: Treat your React components like backend services. Use standard Node.js debugging tools, including debugger statements and request tracing utilities, to monitor performance.
  • Preventative checks: Validate environment variables at runtime and handle edge cases explicitly. Test failure scenarios locally to ensure your fallback mechanisms trigger correctly.

In practice, debugging RSCs means treating your frontend code with the same rigor as backend endpoints. Solid logging helps you identify the root cause in the terminal, while error boundaries isolate the failure on the client, keeping the rest of the application functional.

Caching in Next.js 16+

Caching in Next.js 16 should be explained separately from RSC and Server Actions. RSC defines how components render on the server and how the RSC Payload is streamed to the client, while caching controls whether data, component output, or function results can be reused across requests. In the App Router, modern caching is centered on Cache Components and the use cache directive, which we will cover in detail in a later section.

For now, it is enough to know that data fetching is not cached by default in the current Cache Components model. If you want a route, component, or function to be reusable across requests, you mark it explicitly as cacheable with use cache. Next.js can apply this directive at the file level so that every export in the file is cached, or inline at the top of an individual function or component so that only that specific unit is cached.

A simple example looks like this:

import { cacheTag } from 'next/cache'

type Product = {
  id: string
  name: string
  price: number
}

async function getProducts(): Promise<Product[]> {
  'use cache'
  cacheTag('products')

  const res = await fetch('https://api.example.com/products')
  if (!res.ok) {
    throw new Error('Failed to fetch products')
  }

  return res.json() as Promise<Product[]>
}

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <main>
      <h1>Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} — ${product.price}
          </li>
        ))}
      </ul>
    </main>
  )
}

In this example, getProducts() is explicitly marked as cacheable, and the products tag can later be used for invalidation. Tag-based invalidation is part of the current revalidation model in Next.js. You can associate cached results with tags and then invalidate all matching entries when underlying data changes.

After a mutation, you usually refresh cached content with revalidation. The two most common APIs are revalidatePath() and revalidateTag(). revalidatePath() invalidates cache entries for a specific route path, while revalidateTag() invalidates entries associated with a cache tag. In the App Router, revalidatePath() can be called in Server Functions and Route Handlers, and regeneration happens on the next request.

For example:

'use server'
import { revalidateTag } from 'next/cache'
type CreateProductInput = {
  name: string
  price: number
}

export async function createProduct(input: CreateProductInput): Promise<void> {
  // Validate and sanitize before persistence
  await db.product.create({ data: input })

  revalidateTag('products', 'max')
}

Caching in Next.js 16 deserves its own mental model. Older explanations often revolved around cache: 'no‑store' and route segment options, but that is no longer the best place to start. In Next.js 16, the modern approach is built around Cache Components, the use cache directive, and explicit revalidation.

A simple way to think about it is this: RSC determine where component logic runs, use server defines server-only functions for writes and mutations, and use cache tells Next.js that a piece of work can be reused across requests. When data changes, you refresh the affected output with tools such as revalidatePath() or revalidateTag().

That separation makes the system much easier to reason about. Rendering, mutations, caching, and revalidation are related, but they are not the same concern. Understanding that distinction is the key to writing clear, predictable Next.js applications.

Writing optimized RSC

The use server directive is particularly useful for:

  • Form submissions: Processing form data securely on the server
  • Authentication: Handling login/logout operations
  • Database operations: Performing Create, Read, Update, and Delete (CRUD) operations
  • Server-side validation: Validating data before storage
  • File operations: Uploading or processing files

Let's implement a form submission using Server Actions (which use use server):

// app/contact/page.tsx
// Server Component
export default function ContactPage() {
  return (
    <div className="contact-page">
      <h1>Contact Us</h1>
      <form action={submitContactForm}>
        <div className="form-group">
          <label htmlFor="name">Name</label>
          <input type="text" id="name" name="name" required />
        </div>
        <div className="form-group">
          <label htmlFor="email">Email</label>
          <input type="email" id="email" name="email" required />
        </div>
        <div className="form-group">
          <label htmlFor="message">Message</label>
          <textarea id="message" name="message" rows="5" required></textarea>
        </div>
        <button type="submit">Send Message</button>
      </form>
    </div>
  )
}
// Server Action
async function submitContactForm(formData) {
  'use server'
  // Extract form values
  const name = formData.get('name')
  const email = formData.get('email')
  const message = formData.get('message')
  // Validate inputs
  if (!name || !email || !message) {
    throw new Error('All fields are required')
  }
  // Process the submission (e.g., save to database, send email)
  try {
    await saveContactMessage({ name, email, message })
    // Redirect to success page
    redirect('/contact/success')
  } catch (error) {
    console.error('Failed to submit form:', error)
    throw new Error('Failed to submit. Please try again.')
  }
}
// Backend function (never exposed to client)
async function saveContactMessage(data) {
  // Save to database, send notification emails, etc.
}

This approach is powerful because:

  • The form is rendered as a Server Component
  • Form submission is handled by a server function
  • No client JavaScript is needed for the form itself
  • You can add client components for enhanced validation if needed

Reducing client-side JavaScript with RSC

A key strategy for optimizing performance is minimizing the amount of JavaScript sent to the client. Here are practical approaches:

  • Keep large dependencies server-side: If you need to use large libraries like date‑fns, lodash, or markdown processors, try to use them in Server Components:
    // blog-post.tsx (Server Component)
    import { marked } from 'marked' // A markdown processor
    import { format } from 'date-fns' // Date formatting library
    
    export default async function BlogPost({ id }) {
      const post = await fetchPost(id)
     
      // Process markdown on the server
      const htmlContent = marked(post.content)
     
      // Format dates on the server
      const formattedDate = format(new Date(post.publishedAt), 'MMMM dd, yyyy')
     
      return (
        <article>
          <h1>{post.title}</h1>
          <time>{formattedDate}</time>
         
          {/* Rendered HTML is sent to the client, not the markdown processor */}
          <div dangerouslySetInnerHTML={{ __html: htmlContent }} />
        </article>
      )
    }
  • Create islands of interactivity: Make most of your UI server-rendered, with small islands of client interactivity:
    // page.tsx (Server Component)
    import LikeButton from './like-button'
    export default async function ProductPage({ productId }) {
      const product = await fetchProduct(productId)
      const reviews = await fetchReviews(productId)
    
      return (
        <div className="product-page">
          <h1>{product.name}</h1>
          <p>{product.description}</p>
          <div className="product-price">${product.price}</div>  
          {/* Only this button is a Client Component */}
          <LikeButton productId={productId} />
          <h2>Reviews</h2>
          <div className="reviews-list">
            {reviews.map(review => (
              <div key={review.id} className="review">
                <p>{review.text}</p>
                <p>- {review.author}</p>
              </div>
            ))}
          </div>
        </div>
      )
    }
    
    // like-button.tsx
    'use client'
    
    import { useState } from 'react'
    import { likeProduct } from './actions'
    
    export default function LikeButton({ productId }) {
      const [liked, setLiked] = useState(false)
      return (
        <button
          onClick={async () => {
            await likeProduct(productId)
            setLiked(true)
          }}
          className={liked ? 'liked' : ''}
        >
          {liked ? 'Liked!' : 'Like'}
        </button>
      )
    }
    
    // actions.js
    'use server'
    export async function likeProduct(productId) {
      // Server-side logic to record the like
      await db.likes.create({ productId })
    }

Managing component rendering across the server and client

When building an application with RSC, you need to think carefully about which parts of your application should render on the server versus the client. Here are some guidelines:

Server Components for

Client Components for

Static content

Interactive UI elements

Data fetching

Anything that uses React hooks

SEO-critical content

Components that need browser APIs

Sections that use large dependencies

Real-time updates with WebSockets

Table 1.2 – Server Components vs Client Components

Let's see how to structure a dashboard page with this hybrid approach:

// app/dashboard/page.tsx (Server Component)
import DashboardStats from './dashboard-stats' // Server Component
import RecentActivity from './recent-activity' // Server Component
import UserSettings from './user-settings' // Client Component
import LiveNotifications from './live-notifications' // Client Component

export default async function DashboardPage() {
  const user = await getCurrentUser()
  const stats = await fetchUserStats(user.id)
  const recentActivity = await fetchRecentActivity(user.id)
 
  return (
    <div className="dashboard">
      <h1>Welcome back, {user.name}</h1>
     
      {/* Server Components - static or data-intensive */}
      <DashboardStats stats={stats} />
      <RecentActivity activity={recentActivity} />
     

      {/* Client Components - interactive elements */}
      <UserSettings user={user} />
      <LiveNotifications userId={user.id} />
    </div>
  )
}

Handling side effects and server dependencies in RSC

Server Components can directly interact with server-side resources, but you need to handle them properly:

  • Database connections:
    // lib/db.ts - Shared database utility
    import { Pool } from 'pg'
    
    let pool
    
    if (!global.pool) {
      global.pool = new Pool({
        connectionString: process.env.DATABASE_URL,
        ssl: process.env.NODE_ENV === 'production'
      })
    }
    
    pool = global.pool
    
    export async function query(text, params) {
      return pool.query(text, params)
    }
    
    // Using in a Server Component
    async function ProductList() {
      const { rows } = await query('SELECT * FROM products ORDER BY created_at DESC LIMIT 10')
     
      return (
        <div>
          <h2>Latest Products</h2>
          <ul>
            {rows.map(product => (
              <li key={product.id}>{product.name} - ${product.price}</li>
            ))}
          </ul>
        </div>
      )
    }
    
  • Caching considerations:
    import { cacheLife, cacheTag } from 'next/cache'
    
    type User = {
      name: string
      bio: string
    }
    
    async function UserProfile({ id }: { id: string }) {
      'use cache'
      cacheTag(`user:${id}`)
      cacheLife('hours')
    
      const user = await fetch(`https://api.example.com/users/${id}`, {
        cache: 'force-cache', // Cache indefinitely until revalidated
      }).then((res) => res.json() as Promise<User>)
    
      return (
        <div className="profile">
          <h1>{user.name}</h1>
          <p>{user.bio}</p>
        </div>
      )
    }

    Caching in Next.js has evolved into a highly explicit, opt-in model. Data fetching is now uncached by default to ensure users always receive fresh data.

    If you are following older tutorials from the Next.js 13/14 era, be aware that their automatic caching behavior no longer applies.

    Next.js 16 takes this philosophy further by introducing Cache Components. Instead of configuring caching per individual fetch request, you now declare caching around the logical work itself using the use cache directive. In this modern approach, you explicitly mark operations to be cached, tune them with helpers like cacheLife and cacheTag, and refresh stale results using revalidatePath or revalidateTag. While legacy options like unstable_cache exist for backward compatibility, the use cache directive is the definitive foundation for building modern Next.js applications:

    type Stats = {
      activeUsers: number
      visits: number
    }
    
    // Fresh on every request
    async function DynamicStats() {
      const stats = await fetch('https://api.example.com/stats').then(
        (res) => res.json() as Promise<Stats>
      )
    
      return (
        <div>
          <h2>Current Stats</h2>
          <p>Active Users: {stats.activeUsers}</p>
          <p>Total Visits: {stats.visits}</p>
        </div>
      )
    }
  • Error handling:
    Server Components should gracefully handle errors:
    async function ProductDetails({ id }) {
      try {
        const product = await fetchProduct(id)
       
        return (
          <div>
            <h1>{product.name}</h1>
            <p>{product.description}</p>
          </div>
        )
      } catch (error) {
        // This will be rendered on the server
        return (
          <div className="error-state">
            <h2>Failed to load product</h2>
            <p>Please try again later</p>
          </div>
        )
      }
    }

We've now covered the essential patterns for building hybrid RSC applications: strategically choosing between Server and Client Components, managing server-side resources like databases, implementing proper caching strategies, and handling errors gracefully. These patterns form the foundation for building robust, performant React applications that leverage the best of both server and client rendering.

Differentiating server and client components

It's essential to understand the difference between these two directives:

  • use client: Marks a component or file to be executed on the client, enabling interactivity and browser APIs.
  • use server: Marks a function to be executed on the server, even when called from client code.

Here's how they work together:

// Button.tsx
'use client'

import { useState } from 'react'
import { incrementCounter } from './actions'

export default function Button() {
  const [count, setCount] = useState(0)
 
  const handleClick = async () => {
    // Call a server function from client code
    const newCount = await incrementCounter(count)
    setCount(newCount)
  }
 
  return (
    <button onClick={handleClick}>
      Count: {count}
    </button>
  )
}

// actions.ts
'use server'

export async function incrementCounter(currentCount) {
  // This runs on the server
  console.log('Incrementing counter on server')
  // Could also update a database here
  return currentCount + 1
}

Key takeaways are:

  • Server Components are the default in the App Router
  • Client Components are explicitly marked with use client
  • Server functions are explicitly marked with use server
  • Client Components can call server functions
  • Server Components cannot use hooks or event handlers

Mixing server and client components in a Next.js application

One of the strengths of the RSC model is the ability to seamlessly mix Server and Client Components. Let's see a practical example:

// app/products/[id]/page.tsx (Server Component)
import ProductDetails from './product-details' // Server Component
import AddToCartButton from './add-to-cart-button' // Client Component
import RelatedProducts from './related-products' // Server Component
import CustomerReviews from './customer-reviews' // Server Component
import ReviewForm from './review-form' // Client Component
import { fetchProduct, fetchRelatedProducts, fetchProductReviews } from '@/lib/api'

interface PageProps {
  params: Promise<{ id: string }>
}

export default async function ProductPage({ params }: PageProps) {
  const { id: productId } = await params

  const [product, relatedProducts, reviews] = await Promise.all([
    fetchProduct(productId),
    fetchRelatedProducts(productId),
    fetchProductReviews(productId),
  ])

  return (
    <div>
      <ProductDetails product={product} />
      <AddToCartButton productId={product.id} />
      <RelatedProducts products={relatedProducts} />
      <CustomerReviews reviews={reviews} />
      <ReviewForm productId={product.id} />
    </div>
  )
}

This approach allows you to:

  • Load product data on the server
  • Render most UI on the server
  • Add interactive elements only where needed
  • Keep the client bundle small
  • Improve initial page load performance

Strategies for passing data between server and client components

When working with both Server and Client Components, you need strategies for sharing data:

  • Props Passing (Server → Client): The most straightforward way is to pass data as props:
    // Server Component
    import UserProfile from './user-profile'
    import { fetchUser } from '@/lib/api'
    
    interface User {
      id: string
      name: string
      bio: string
    }
    
    export default async function Page() {
      const user: User = await fetchUser()
      return <UserProfile user={user} />
    }
    
    
    
    // Client Component
    'use client'
    
    interface User {
      id: string
      name: string
      bio: string
    }
    
    interface UserProfileProps {
      user: User
    }
    
    export default function UserProfile({ user }: UserProfileProps) {
      return (
        <div>
          <h1>{user.name}</h1>
          <button onClick={() => alert(`Hello, ${user.name}!`)}>
            Say Hello
          </button>
        </div>
      )
    }
  • Server Actions (Client → Server): For sending data from client to server:
    // actions.ts (Server Actions)
    'use server'
    import { db } from '@/lib/db'
    
    interface UserData {
      name: string
      bio: string
    }
    
    interface UpdateResult {
      id: string
      name: string
      bio: string
    }
    
    export async function updateUserProfile(
      userId: string,
      data: UserData,
    ): Promise<UpdateResult> {
      await db.users.update({ where: { id: userId }, data })
      return db.users.findUniqueOrThrow({ where: { id: userId } })
    }
    
    // profile-form.tsx (Client Component)
    'use client'
    
    import { useState } from 'react'
    import { updateUserProfile } from './actions'
    
    export default function ProfileForm({ user }) {
      const [name, setName] = useState(user.name)
      const [bio, setBio] = useState(user.bio)
     
      const handleSubmit = async (e) => {
        e.preventDefault()
        const updatedUser = await updateUserProfile(user.id, { name, bio })
        // Do something with updated user...
      }
     
      return (
        <form onSubmit={handleSubmit}>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
          <textarea
            value={bio}
            onChange={(e) => setBio(e.target.value)}
          />
          <button type="submit">Update Profile</button>
        </form>
      )
    }
  • Context with React Cache (Shared State): For more complex state management:
    // lib/user-cache.ts
    'use server'
    
    import { cache } from 'react'
    import { db } from '@/lib/db'
    
    export interface User {
      id: string
      name: string
      bio: string
    }
    
    export const getUser = cache(async (userId: string): Promise<User> => {
      return db.users.findUniqueOrThrow({ where: { id: userId } })
    })
    
    // context/user-provider.tsx
    'use client'
    
    import { createContext, useContext, useState, useEffect } from 'react'
    
    const UserContext = createContext(null)
    
    export function UserProvider({ initialUser, children }) {
      const [user, setUser] = useState(initialUser)
      return (
        <UserContext.Provider value={{ user, setUser }}>
          {children}
        </UserContext.Provider>
      )
    }
    
    export function useUser() {
      return useContext(UserContext)
    }
    
    // app/user/[id]/layout.tsx (Server Component)
    import type { ReactNode } from 'react'
    import { UserProvider } from '@/context/user-provider'
    import { getUser } from '@/lib/user-cache'
    
    interface LayoutProps {
      params: Promise<{ id: string }>
      children: ReactNode
    }
    
    export default async function UserLayout({ params, children }: LayoutProps) {
      const { id } = await params
      const user = await getUser(id)
    
      return <UserProvider initialUser={user}>{children}</UserProvider>
    }

Performance considerations when combining server and client logic

When mixing Server and Client Components, keep these performance considerations in mind:

  • Component boundaries: Create clear boundaries between server and client parts:
    // Bad: Fine-grained mixing
    function ProductPage() {
      return (
        <div>
          <ServerComponent1 />
          <ClientComponent1 />
          <ServerComponent2 />
          <ClientComponent2 />
          <ServerComponent3 />
          <ClientComponent3 />
        </div>
      )
    }
    // Better: Chunked by logical sections
    function ProductPage() {
      return (
        <div>
          <ProductInfo /> {/* Server Component with all product details */}
          <InteractiveSection /> {/* Client Component with interactive elements */}
          <RelatedProducts /> {/* Server Component with all related products */}
        </div>
      )
    }
  • Avoid prop drilling through client components:
    // Bad: Server data passes through client component
    async function Page() {
      const user = await getUser()
     
      return (
        <ClientWrapper>
          <ServerComponent user={user} />
        </ClientWrapper>
      )
    }
    
    // Better: Keep server data flow on server
    async function Page() {
      const user = await getUser()
     
      return (
        <>
          <ServerComponent user={user} />
          <ClientWrapper />
        </>
      )
    }
  • Use suspense boundaries strategically:
    import { Suspense } from 'react'
    
    function ProductPage() {
      return (
        <div>
          <h1>Product Details</h1>
         
          {/* Critical UI loads first */}
          <ProductBasicInfo />
         
          {/* Less critical UI can suspend */}
          <Suspense fallback={<p>Loading reviews...</p>}>
            <ProductReviews />
          </Suspense>
         
          <Suspense fallback={<p>Loading recommendations...</p>}>
            <RecommendedProducts />
          </Suspense>
        </div>
      )
    }

To maximize performance when combining Server and Client Components, focus on creating clear architectural boundaries by grouping related functionality rather than alternating between server and client components throughout your component tree. Avoid passing server-fetched data through Client Components as props, which forces unnecessary serialization and can break the server-rendering benefits—instead, keep server data flows entirely on the server side and let Client Components handle their own state and interactions. Implement Suspense boundaries strategically around non-critical Server Components like reviews, recommendations, or analytics data, allowing your core UI to render immediately while less essential content loads progressively. Consider the network waterfall effects of your component architecture, minimize the JavaScript bundle size by keeping interactive logic in focused Client Components, and leverage React's concurrent features to prioritize critical rendering paths. Remember that the goal is to deliver the fastest possible initial page load while maintaining rich interactivity where needed—measure your Core Web Vitals regularly and adjust your server/client boundaries based on real user performance data rather than assumptions.

Efficient data fetching with RSC

One of the biggest advantages of Server Components is the ability to fetch data directly without useEffect:

// Traditional Client Component data fetching
function ClientProductList() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
 
  useEffect(() => {
    async function fetchProducts() {
      try {
        const response = await fetch('/api/products')
        if (!response.ok) {
          throw new Error('Failed to fetch products')
        }
        const data = await response.json()
        setProducts(data)
      } catch (err) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }
   
    fetchProducts()
  }, [])
 
  if (loading) return <div>Loading products...</div>
  if (error) return <div>Error: {error}</div>
 
  return (
    <div className="products-grid">
      {products.map(product => (
        <div key={product.id} className="product-card">
          <h3>{product.name}</h3>
          <p>${product.price}</p>
        </div>
      ))}
    </div>
  )
}

// Server Component data fetching
async function ServerProductList() {
  // Direct data fetching - no useEffect, no loading state
  const products = await fetchProducts()
 
  return (
    <div className="products-grid">
      {products.map(product => (
        <div key={product.id} className="product-card">
          <h3>{product.name}</h3>
          <p>${product.price}</p>
        </div>
      ))}
    </div>
  )
}

// Server-side function (not exposed to client)
async function fetchProducts() {
  // This could be a direct database query
  // For example purposes, we're still using fetch
  const response = await fetch('https://api.example.com/products', {
    // Next.js specific options
    cache: 'no-store'
  })
 
  if (!response.ok) {
    throw new Error('Failed to fetch products')
  }
 
  return response.json()
}

Benefits of Server Component data fetching:

  • No loading states to manage
  • No useEffect dependency arrays to maintain
  • No risk of waterfalls (multiple sequential API calls)
  • Direct database access if needed
  • Better error handling with try/catch
  • Data is available before the component renders

You can also fetch data in parallel for better performance:

async function DashboardPage() {
  // Start all fetch requests in parallel
  const productsPromise = fetchProducts()
  const usersPromise = fetchUsers()
  const statsPromise = fetchStats()
  // Wait for all to complete
  const [products, users, stats] = await Promise.all([
    productsPromise,
    usersPromise,
    statsPromise
  ])
 
  return (
    <div>
      <ProductsSection products={products} />
      <UsersSection users={users} />
      <StatsSection stats={stats} />
    </div>
  )
}

We've now mastered the essential data fetching patterns for Server Components: fetching data directly without client-side hooks like useEffect, optimizing performance through parallel requests with Promise.all and Promise.allSettled, enabling progressive page loads with React Suspense for streaming, and building resilient applications through comprehensive error handling using try/catch blocks and error.js files.

Combining RSC with streaming and suspense for faster page loads

React Server Components work beautifully with React's Suspense feature to enable streaming. This allows the server to send parts of the page as they become ready, rather than waiting for all data:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import Loading from './loading'

export default function DashboardPage() {
  return (
    <div className="dashboard">
      <h1>Dashboard</h1>
     
      {/* Critical UI renders first */}
      <UserWelcome />
     
      {/* These components can load in parallel */}
      <div className="dashboard-grid">
        <Suspense fallback={<Loading type="stats" />}>
          <Stats />
        </Suspense>
       
        <Suspense fallback={<Loading type="recent-orders" />}>
          <RecentOrders />
        </Suspense>
       
        <Suspense fallback={<Loading type="notifications" />}>
          <Notifications />
        </Suspense>
      </div>
    </div>
  )
}

// Each component can fetch its own data
async function Stats() {
  // This fetch might take a while
  const stats = await fetchStats()
 
  return (
    <div className="stats-card">
      <h2>Statistics</h2>
      <p>Revenue: ${stats.revenue}</p>
      <p>Orders: {stats.orders}</p>
      <p>Customers: {stats.customers}</p>
    </div>
  )
}

async function RecentOrders() {
  // This might be a separate API call
  const orders = await fetchRecentOrders()
 
  return (
    <div className="orders-card">
      <h2>Recent Orders</h2>
      <ul>
        {orders.map(order => (
          <li key={order.id}>
            Order #{order.id} - ${order.total}
          </li>
        ))}
      </ul>
    </div>
  )
}

// loading.tsx component
export default function Loading({ type }) {
  return (
    <div className={`loading-skeleton ${type}`}>
      <div className="loading-header"></div>
      <div className="loading-body"></div>
    </div>
  )
}

This approach has several benefits:

  • The user sees content progressively as it becomes available
  • Critical UI appears first
  • Slower data fetches don't block the entire page
  • Each component can load independently
  • The page becomes interactive in stages

Streaming works by:

  • Sending the HTML for the main layout and any instantly available content
  • Replacing Suspense fallbacks with real content as data becomes available
  • Sending JavaScript only for Client Components

Caching strategies with RSC to avoid unnecessary fetching

Effective caching is crucial for performance. In Next.js, you have several caching options:

  • Request memoization: React automatically memoizes fetch requests with the same URL and options within a React Server Component tree:
    async function ProductInfo({ id }) {
      // This fetch is automatically memoized
      const product = await fetch(`https://api.example.com/products/${id}`)
        .then(res => res.json())
     
      return <div>{product.name}</div>
    }
    
    async function ProductPrice({ id }) {
      // This won't cause a second fetch - it reuses the cached result
      const product = await fetch(`https://api.example.com/products/${id}`)
        .then(res => res.json())
     
      return <div>${product.price}</div>
    }
    
    export default function ProductPage({ id }) {
      return (
        <>
          <ProductInfo id={id} />
          <ProductPrice id={id} /> {/* Uses cached data */}
        </>
      )
    }
  • Next.js data cache: Next.js provides caching options for fetch:
    // Cache data until manually invalidated (default)
    async function getProduct(id) {
      const res = await fetch(`https://api.example.com/products/${id}`)
      return res.json()
    }
    // Cache data for 60 seconds
    async function getVolatileData() {
      const res = await fetch('https://api.example.com/stats', {
        next: { revalidate: 60 } // Seconds
      })
      return res.json()
    }
    // Never cache this data
    async function getLiveData() {
      const res = await fetch('https://api.example.com/live-stats', {
        cache: 'no-store'
      })
      return res.json()
    }
  • React Cache for custom functions: For non-fetch data access, use React's cache function:
    // lib/utils/database.ts
    import { cache } from 'react'
    import { db } from '@/lib/db'
    
    export const getUser = cache(async (userId) => {
      // This database query will be cached
      return await db.users.findUnique({
        where: { id: userId }
      })
    })
    export const getProducts = cache(async ({ category, limit = 10 }) => {
      return await db.products.findMany({
        where: { category },
        take: limit,
        orderBy: { createdAt: 'desc' }
      })
    })
  • Revalidation strategies: Next.js offers several ways to invalidate cache:
    // Time-based revalidation
    fetch('https://api.example.com/products', {
      next: { revalidate: 3600 } // Revalidate every hour
    })
    
    // On-demand revalidation in Server Actions
    'use server'
    
    import { revalidatePath, revalidateTag } from 'next/cache'
    
    export async function createProduct(data) {
      // Create the product in the database
      await db.products.create({ data })
     
      // Invalidate all product pages
      revalidatePath('/products')
     
      // Or use tags for more granular control
      revalidateTag('products', 'max')
    }
    
    // Using tags with fetch
    fetch('https://api.example.com/products', {
      next: { tags: ['products'] }
    })
  • Combining different caching strategies: For optimal performance, combine strategies based on data characteristics:
    async function ShopPage() {
      // Static data - long cache
      const categories = await getCategories()
     
      // Semi-dynamic - revalidate periodically
      const featuredProducts = await getFeaturedProducts()
     
      // Highly dynamic - no cache
      const flashSales = await getActiveFlashSales()
     
      return (
        <div>
          <CategoriesList categories={categories} />
          <FeaturedProducts products={featuredProducts} />
          <FlashSales sales={flashSales} />
        </div>
      )
    }
    
    async function getCategories() {
      // Categories rarely change - cache until manually invalidated
      return await fetch('https://api.example.com/categories').then(res => res.json())
    }
    
    async function getFeaturedProducts() {
      // Featured products change daily - revalidate every hour
      return await fetch('https://api.example.com/featured', {
        next: { revalidate: 3600 }
      }).then(res => res.json())
    }
    
    async function getActiveFlashSales() {
      // Flash sales change constantly - never cache
      return await fetch('https://api.example.com/flash-sales', {
        cache: 'no-store'
      }).then(res => res.json())
    }

We've now explored the complete caching toolkit for RSC applications: automatic request memoization for deduplication, Next.js data cache with flexible revalidation options, React's cache function for custom data sources, and strategic cache invalidation through revalidatePath and revalidateTag. The final example demonstrates how to thoughtfully combine these strategies based on data volatility—using long-term caching for static content, time-based revalidation for semi-dynamic data, and no caching for real-time information.

What's new in React 19.2?

The React team has been busy. React 19.2 arrives with a collection of features that feel less like revolutionary changes and more like the framework finally catching up to what developers have been asking for all along. There's a certain elegance to this release; it's not about reinventing React, but about refining the developer experience and addressing real-world performance challenges that have plagued production applications for years.

React and React Native are transitioning to the React Foundation, an independent organization under the Linux Foundation with a governing board that includes Amazon, Meta, Microsoft, Vercel, and other major companies. While Meta remains committed with a five-year partnership including over $3 million in funding, this move shifts React from a Meta-led project to a vendor-neutral, community-driven ecosystem with independent governance.

The <Activity /> component

At first glance, <Activity /> looks like it belongs in the same family as spinners, skeletons, and other loading indicators. It does not. Its purpose is subtler, and more powerful. <Activity /> is about preserving UI in the background, not about signaling that work is in progress.

When an activity is hidden, React removes its children from view, cleans up their Effects, and keeps their state in memory. That means the interface can disappear without being discarded. When it becomes visible again, it returns in the same state it had before. The user is not starting over. The UI simply comes back.

This makes <Activity /> especially useful for parts of the interface that move in and out of view but should not lose their place: a sidebar full of filters, a tab panel, a detail drawer, or a settings section the user may revisit a moment later. Instead of unmounting that subtree and rebuilding it from scratch, you can hide it and restore it when needed:

import { Activity, useState } from 'react'

export function Dashboard() {
  const [showSidebar, setShowSidebar] = useState(true)

  return (
    <div className="layout">
      <button onClick={() => setShowSidebar((v) => !v)}>
        Toggle sidebar
      </button>

      <Activity mode={showSidebar ? 'visible' : 'hidden'}>
        <aside className="sidebar">
          <h2>Filters</h2>
          <label>
            Search
            <input type="text" />
          </label>
        </aside>
      </Activity>

      <main>
        <h1>Dashboard</h1>
        <p>Main content goes here.</p>
      </main>
    </div>
  )
}

The important prop here is mode, which controls whether the wrapped content is visible or hidden. That is the central idea behind the component. There is no pending prop, and there is no type="refresh" mode. <Activity /> is not a loading primitive and does not decide how to represent progress.

That distinction matters because it is easy to confuse <Activity /> with useTransition. The two can appear in similar conversations, but they solve different problems. useTransition lets you mark updates as non-urgent and gives you a way to show pending feedback while React is working. <Activity />, by contrast, manages visibility while preserving state. One is about scheduling updates. The other is about keeping UI alive when it is temporarily out of sight.

Seen this way, <Activity /> is less like a spinner and more like a backstage area. The interface steps out of view, but it does not cease to exist. When it returns, it picks up where it left off. That small shift in mental model is the key to understanding why the component matters.

useEffectEvent: the hook we've been waiting for

Every React developer has written this code: an effect that depends on a function, which depends on props, which causes the effect to re-run way too often. The workarounds have become cargo-cult patterns: useCallback chains that make your component look like a game of dependency Jenga, the latest ref pattern with useRef, or that linter comment we all copy-paste to disable the exhaustive-deps warning.

useEffectEvent formalizes this pattern by separating what changes from what reacts to changes. It's a way to access the latest props and state inside an effect without making the effect re-run when those values change:

import { useEffect, useEffectEvent, useState } from 'react'

interface AnalyticsTrackerProps {
  userId: string
  pageName: string
}

export function AnalyticsTracker({ userId, pageName }: AnalyticsTrackerProps) {
  const [sessionDuration, setSessionDuration] = useState(0)

  // This function always sees the latest userId and pageName
  // but doesn't cause effects to re-run when they change
  const logEvent = useEffectEvent((eventName: string, data: object) => {
    analytics.track(eventName, {
      userId,
      pageName,
      timestamp: Date.now(),
      ...data,
    })
  })

  // This effect only runs once on mount
  useEffect(() => {
    const startTime = Date.now()
    logEvent('page_view', { startTime })

    const interval = setInterval(() => {
      const duration = Math.floor((Date.now() - startTime) / 1000)
      setSessionDuration(duration)
      logEvent('heartbeat', { duration })
    }, 30000)

    return () => {
      const endTime = Date.now()
      const totalDuration = Math.floor((endTime - startTime) / 1000)
      logEvent('page_exit', { totalDuration })
      clearInterval(interval)
    }
  }, []) // Empty deps array - this really only runs once now

  return (
    <div className="fixed bottom-4 right-4 bg-slate-800 text-white px-3 py-2
                    rounded-lg text-sm opacity-50">
      Session: {sessionDuration}s
    </div>
  )
}

Notice how clean the dependency array is now. The effect runs once, but logEvent always has access to the current userId and pageName. No more choosing between correctness and performance. No more useCallback chains that make you question your career choices.

Partial pre-rendering: the best of both worlds

The static versus dynamic debate has defined web development for a decade. Static site generators give you speed but sacrifice interactivity. Server-side rendering gives you dynamic content but at the cost of time-to-first-byte. Partial pre-rendering (PPR) says, why not both?

The idea is elegant: pre-render the static shell of your page at build time, but leave holes where dynamic content will be streamed in at request time. Your users see something instantly, and the personalized bits fill in as they arrive:

// app/dashboard/page.tsx
import { Suspense } from 'react'

// This component will be pre-rendered at build time
function DashboardShell() {
  return (
    <div className="min-h-screen bg-slate-50">
      <header className="bg-white border-b border-slate-200 px-8 py-4">
        <h1 className="text-2xl font-bold text-slate-900">Dashboard</h1>
      </header>

      <main className="p-8">
        <div className="max-w-7xl mx-auto">
          <div className="grid grid-cols-3 gap-6 mb-8">
            {/* Static promotional cards pre-rendered at build time */}
            <div className="bg-gradient-to-br from-blue-500 to-blue-600
                          p-6 rounded-xl text-white">
              <h3 className="text-lg font-semibold mb-2">New Feature</h3>
              <p className="text-blue-100">
                Check out our latest updates and improvements.
              </p>
            </div>

            {/* Dynamic content will be streamed in */}
            <Suspense fallback={<MetricCardSkeleton />}>
              <UserMetrics />
            </Suspense>

            <Suspense fallback={<MetricCardSkeleton />}>
              <RecentActivity />
            </Suspense>
          </div>

          <Suspense fallback={<FeedSkeleton />}>
            <PersonalizedFeed />
          </Suspense>
        </div>
      </main>
    </div>
  )
}

// This component fetches data at request time
async function UserMetrics() {
  const metrics = await fetchUserMetrics() // Server-side data fetching

  return (
    <div className="bg-white p-6 rounded-xl shadow-sm">
      <h3 className="text-sm font-medium text-slate-600 mb-2">
        Your Progress
      </h3>
      <p className="text-3xl font-bold text-slate-900">{metrics.score}</p>
      <p className="text-sm text-green-600 mt-1">
        +{metrics.improvement}% this week
      </p>
    </div>
  )
}

async function RecentActivity() {
  const activities = await fetchRecentActivity()

  return (
    <div className="bg-white p-6 rounded-xl shadow-sm">
      <h3 className="text-sm font-medium text-slate-600 mb-3">
        Recent Activity
      </h3>
      <div className="space-y-2">
        {activities.slice(0, 3).map((activity) => (
          <div key={activity.id} className="text-sm text-slate-700">
            {activity.description}
          </div>
        ))}
      </div>
    </div>
  )
}

function MetricCardSkeleton() {
  return (
    <div className="bg-white p-6 rounded-xl shadow-sm animate-pulse">
      <div className="h-4 bg-slate-200 rounded w-1/2 mb-3"></div>
      <div className="h-8 bg-slate-200 rounded w-3/4"></div>
    </div>
  )
}

export default DashboardShell

With PPR enabled in your Next.js config, this page will be partially pre-rendered. The shell, navigation, layout, and static content are generated at build time and served instantly from the CDN. The dynamic components wrapped in Suspense boundaries are rendered on-demand when the user requests the page, with their content streaming in as it becomes available.

The user experience is immediate feedback followed by progressive enhancement. No more blank screens while waiting for database queries. No more choosing between performance and personalization.

Batching suspense boundaries for SSR

Server-side rendering has always had an awkward problem: what happens when you have multiple suspense boundaries on a page? Do you wait for all of them? Stream them one by one? Send them as they complete?

React 19.2 introduces intelligent batching. Related suspense boundaries, those that would appear on screen at the same time, are batched together and flushed as a group. This means fewer round-trips, less layout shifting, and a smoother perceived loading experience:

// app/article/[slug]/page.tsx
import { Suspense } from 'react'

async function ArticlePage({ params }: { params: { slug: string } }) {
  return (
    <article className="max-w-4xl mx-auto px-8 py-12">
      {/* These boundaries are visually grouped, so React batches them */}
      <Suspense fallback={<HeaderSkeleton />}>
        <ArticleHeader slug={params.slug} />
      </Suspense>

      <div className="mt-8 prose prose-slate max-w-none">
        <Suspense fallback={<ContentSkeleton />}>
          <ArticleContent slug={params.slug} />
        </Suspense>
      </div>

      <aside className="mt-12 border-t border-slate-200 pt-8">
        {/* This is separate, so it might flush independently */}
        <Suspense fallback={<CommentsSkeleton />}>
          <CommentsSection slug={params.slug} />
        </Suspense>
      </aside>
    </article>
  )
}

React analyzes your component tree and makes intelligent decisions about what to batch together. The header and content arrive together because they're part of the primary reading experience. Comments might come later; they're useful but not critical to the initial render.

SSR: web streams support for Node

React 18 introduced two streaming APIs for server-side rendering, both of which remain the recommended choice in React 19, each optimized for different environments:

  1. renderToPipeableStream: Use this in Node.js environments. It uses Node's native stream API, offers better performance, and supports built-in compression (gzip, brotli). This is still the recommended choice for traditional Node.js servers:
    // server.ts (Node.js)
    import { renderToPipeableStream } from 'react-dom/server'
    import App from './App'
    
    export function handler(req: Request, res: Response) {
      const { pipe } = renderToPipeableStream(<App />, {
        bootstrapScripts: ['/client.js'],
        onShellReady() {
          res.setHeader('Content-Type', 'text/html')
          pipe(res)
        },
        onError(error) {
          console.error('SSR Error:', error)
        },
      })
    }
  2. renderToReadableStream: Use this in edge runtimes (Cloudflare Workers, Deno, Vercel Edge Functions) that support Web Streams but not Node.js APIs:
    // server.ts (Edge runtime)
    import { renderToReadableStream } from 'react-dom/server'
    import App from './App'
    export async function handler(request: Request) {
      const stream = await renderToReadableStream(<App />, {
        bootstrapScripts: ['/client.js'],
        onError(error) {
          console.error('SSR Error:', error)
        },
      })
    
      return new Response(stream, {
        headers: {
          'Content-Type': 'text/html'
        }
      })
    }

The Web Streams API provides portability across edge runtimes, but don't switch from renderToPipeableStream if you're deploying to Node.js; you'd lose performance benefits and compression support for no gain. Choose the API that matches your deployment target.

eslint-plugin-react-hooks v6: smarter linting

The Rules of Hooks have always been somewhat magical, patterns that React requires but that JavaScript itself doesn't enforce. The ESLint plugin has done heroic work catching violations, but it's also been a source of frustration with false positives and overly strict warnings.

Version 6 understands React better. It knows about useEffectEvent and doesn't complain about its dependencies. It understands cacheSignal and doesn't force you to add it to dependency arrays. Most importantly, it's gotten better at understanding your intent:

import { useEffect, useEffectEvent, useState } from 'react'

interface TimerProps {
  onTick: (count: number) => void
  interval: number
}

export function Timer({ onTick, interval }: TimerProps) {
  const [count, setCount] = useState(0)

  // v6 understands that this doesn't need to be in the deps array
  const handleTick = useEffectEvent((currentCount: number) => {
    onTick(currentCount)
  })

  useEffect(() => {
    const timer = setInterval(() => {
      setCount((c) => {
        const newCount = c + 1
        handleTick(newCount)
        return newCount
      })
    }, interval)

    return () => clearInterval(timer)
  }, [interval]) // Only interval needs to be here - no lint errors!

  return (
    <div className="text-center p-8">
      <div className="text-6xl font-bold text-slate-900 mb-4">{count}</div>
      <div className="text-slate-600">seconds elapsed</div>
    </div>
  )
}

The linter is finally working with you instead of against you. It catches real problems while staying out of your way when you're using the new patterns correctly.

Update the default useId prefix

React 19 changes the default prefix for useId from colons (:r1:) to a CSS-safe format. This isn't about collision resistance but compatibility with the View Transitions API.

The View Transitions API uses CSS selectors to match elements during page transitions. The old useId format included colons, which are special characters in CSS (used for pseudo-classes like :hover). An ID like :r1: would require escaping in CSS selectors, and the View Transitions API couldn't match these elements without workarounds:

import { useId } from 'react'

export function FormField({ label, type = 'text' }: FormFieldProps) {
  // React 18: ':r1:' (colon prefix, problematic for CSS selectors)
  // React 19: 'r1' or similar CSS-safe format
  const id = useId()

  return (
    <div>
      <label htmlFor={id}>
        {label}
      </label>
      <input id={id} type={type} />
    </div>
  )
}

You still use useId exactly as before, since the API itself has not changed. The difference is that generated IDs now work seamlessly with CSS-based features such as View Transitions, document.querySelector, and CSS attribute selectors. If you have tests or snapshots that assert specific ID formats, they will need to be updated, but your application code remains unchanged.

Summary

React 19.2 represents a thoughtful evolution rather than a revolution, a collection of refinements that make the framework better at what it already does well. The <Activity /> component lets you keep parts of the UI mounted but hidden while preserving their state, useEffectEvent eliminates entire categories of dependency-related bugs, and cacheSignal provides an AbortSignal tied to the lifetime of cache() entries in RSC, making it easier to cancel work that is no longer needed. PPR helps bridge the static-versus-dynamic divide, while smaller improvements like better Suspense batching, Web Streams support in Node, and smarter linting remove friction from everyday development. This is React maturing, becoming more refined and performant without abandoning its core philosophy.

RSC complement this maturation by fundamentally changing where rendering happens. By executing exclusively on the server and sending a serialized RSC Payload (rather than a full client-side JavaScript bundle) to the client, RSC dramatically reduces JavaScript bundle sizes and simplifies data fetching with direct async/await syntax. Next.js has made adoption straightforward through the App Router, where components are Server Components by default and thoughtful use of the use client directive, along with use server for Server Actions, lets you design component boundaries that balance server and client rendering. Combined with streaming via Suspense and appropriate caching strategies, Server Components enable you to build applications that are both powerful and performant, keeping heavy computation server-side while maintaining rich interactivity where it matters.

By understanding these patterns and leveraging both React 19.2's refinements and Server Components' architectural advantages, you'll be equipped to build modern React applications that deliver optimal user experiences. In the next chapter, we'll explore how to combine RSC with Server Actions to create even more complex and interactive applications while maintaining the performance benefits we've discussed here.

Left arrow icon Right arrow icon

Key benefits

  • Updated for React 19 & Next.js 16 with modern, production-ready design patterns
  • Full-stack React mastery with Express, PostgreSQL, Drizzle ORM & CI/CD workflows
  • Build scalable, SEO-first apps and boost developer productivity with AI tools like ChatGPT and Claude
  • Purchase of the print or Kindle book includes a free PDF eBook

Description

React continues to evolve, and so should the way you design, build, and ship applications. This new edition helps you make decisions that work reliably in production, focusing on patterns, real-world insights, and advanced techniques that streamline development and create robust, scalable applications. Along the way, it highlights common anti-patterns that slow teams down and demonstrates practical solutions. The book is organized into three parts, the first of which covers core design principles and modern component architecture. The second moves into an advanced state and data management using tools like Redux Toolkit, Zustand, Server Components, and Drizzle ORM, with a focus on building fast, predictable applications. The third part covers integration and delivery, including testing, CI/CD automation, backend work with Express and PostgreSQL, and deployment strategies that help you ship with confidence. This edition also shows how to bring AI-assisted development into your workflow. You’ll learn how tools like ChatGPT, Claude, and V0 can support tasks such as writing tests, improving documentation, and automating reviews, while still keeping control of quality and intent. By the end, you’ll have a clear understanding of the patterns and practices that shape modern React development and the skills to build future-ready apps.

Who is this book for?

This book is for intermediate to advanced React developers eager to master React and Next.js. Whether you're a frontend developer, full-stack engineer, or tech lead looking to upgrade your skills, this book provides advanced patterns and production-ready techniques you need. It's ideal for developers transitioning from traditional client-side React to server-centric architectures and those seeking to build performant, scalable applications. Teams interested in safe AI-assisted workflows and tools will gain practical, real-world guidance to accelerate delivery without sacrificing quality.

What you will learn

  • Manage state with Redux Toolkit, Zustand, and new React hooks
  • Implement authentication, authorization, and role-based access control with NextAuth.js
  • Write comprehensive tests using Jest, React Testing Library, and Playwright for E2E scenarios
  • Deploy to production with caching, monitoring, and optimization strategies
  • Apply structured prompt templates for refactors, bug triage, and ADR first-drafts
  • Use Claude/ChatGPT to draft components, convert JS-TS, and generate tests with coverage goals

Product Details

Country selected
Publication date, Length, Edition, Language, ISBN-13
Publication date : Aug 18, 2026
Length: 666 pages
Edition : 5th
Language : English
ISBN-13 : 9781806108244
Languages :
Tools :

What do you get with eBook?

Product feature icon Instant access to your Digital eBook purchase
Product feature icon Download this book in EPUB and PDF formats
Product feature icon Access this title in our online reader with advanced features
Product feature icon DRM FREE - Read whenever, wherever and however you want
Product feature icon AI Assistant (beta) to help accelerate your learning
Modal Close icon
Payment Processing...
tick Completed

Billing Address

Product Details

Publication date : Aug 18, 2026
Length: 666 pages
Edition : 5th
Language : English
ISBN-13 : 9781806108244
Languages :
Tools :

Packt Subscriptions

See our plans and pricing
Modal Close icon
$19.99 billed monthly
Feature tick icon Unlimited access to Packt's library of 7,000+ practical books and videos
Feature tick icon Constantly refreshed with 50+ new titles a month
Feature tick icon Exclusive Early access to books as they're written
Feature tick icon Solve problems while you work with advanced search and reference features
Feature tick icon Offline reading on the mobile app
Feature tick icon Simple pricing, no contract
$199.99 billed annually
Feature tick icon Unlimited access to Packt's library of 7,000+ practical books and videos
Feature tick icon Constantly refreshed with 50+ new titles a month
Feature tick icon Exclusive Early access to books as they're written
Feature tick icon Solve problems while you work with advanced search and reference features
Feature tick icon Offline reading on the mobile app
Feature tick icon Choose a DRM-free eBook or Video every month to keep
Feature tick icon PLUS own as many other DRM-free eBooks or Videos as you like for just Can$6 each
Feature tick icon Exclusive print discounts
$279.99 billed in 18 months
Feature tick icon Unlimited access to Packt's library of 7,000+ practical books and videos
Feature tick icon Constantly refreshed with 50+ new titles a month
Feature tick icon Exclusive Early access to books as they're written
Feature tick icon Solve problems while you work with advanced search and reference features
Feature tick icon Offline reading on the mobile app
Feature tick icon Choose a DRM-free eBook or Video every month to keep
Feature tick icon PLUS own as many other DRM-free eBooks or Videos as you like for just Can$6 each
Feature tick icon Exclusive print discounts

Table of Contents

19 Chapters
Chapter 1: Mastering React Server Components (RSC) Chevron down icon Chevron up icon
Chapter 2: Actions, Server Interactions, and Caching Chevron down icon Chevron up icon
Chapter 3: Advanced Error Handling and Debugging Chevron down icon Chevron up icon
Chapter 4: Advanced State Management Techniques Chevron down icon Chevron up icon
Chapter 5: React Anti-Patterns and Best Practices Chevron down icon Chevron up icon
Chapter 6: Styling and Building Scalable Design Systems in React Chevron down icon Chevron up icon
Chapter 7: React Hooks Chevron down icon Chevron up icon
Chapter 8: React Router 7 Chevron down icon Chevron up icon
Chapter 9: Advanced Form Handling in React Chevron down icon Chevron up icon
Chapter 10: Scalable Application Architecture and Project Setup Chevron down icon Chevron up icon
Chapter 11: Authentication and Authorization with NextAuth.js Chevron down icon Chevron up icon
Chapter 12: Building Dynamic and Automatic APIs with Express, Node.js, PostgreSQL, and Drizzle ORM Chevron down icon Chevron up icon
Chapter 13: Internationalization (i18n) in React Applications Chevron down icon Chevron up icon
Chapter 14: Automated Testing for React Applications Chevron down icon Chevron up icon
Chapter 15: Continuous Integration and Delivery (CI/CD) Chevron down icon Chevron up icon
Chapter 16: Optimizing Performance in React Applications Chevron down icon Chevron up icon
Chapter 17: Deploying to Production Chevron down icon Chevron up icon
Other Books You May Enjoy Chevron down icon Chevron up icon
Index Chevron down icon Chevron up icon
Get free access to Packt library with over 7500+ books and video courses for 7 days!
Start Free Trial

FAQs

How do I buy and download an eBook? Chevron down icon Chevron up icon

Where there is an eBook version of a title available, you can buy it from the book details for that title. Add either the standalone eBook or the eBook and print book bundle to your shopping cart. Your eBook will show in your cart as a product on its own. After completing checkout and payment in the normal way, you will receive your receipt on the screen containing a link to a personalised PDF download file. This link will remain active for 30 days. You can download backup copies of the file by logging in to your account at any time.

If you already have Adobe reader installed, then clicking on the link will download and open the PDF file directly. If you don't, then save the PDF file on your machine and download the Reader to view it.

Please Note: Packt eBooks are non-returnable and non-refundable.

Packt eBook and Licensing When you buy an eBook from Packt Publishing, completing your purchase means you accept the terms of our licence agreement. Please read the full text of the agreement. In it we have tried to balance the need for the ebook to be usable for you the reader with our needs to protect the rights of us as Publishers and of our authors. In summary, the agreement says:

  • You may make copies of your eBook for your own use onto any machine
  • You may not pass copies of the eBook on to anyone else
How can I make a purchase on your website? Chevron down icon Chevron up icon

If you want to purchase a video course, eBook or Bundle (Print+eBook) please follow below steps:

  1. Register on our website using your email address and the password.
  2. Search for the title by name or ISBN using the search option.
  3. Select the title you want to purchase.
  4. Choose the format you wish to purchase the title in; if you order the Print Book, you get a free eBook copy of the same title. 
  5. Proceed with the checkout process (payment to be made using Credit Card, Debit Cart, or PayPal)
Where can I access support around an eBook? Chevron down icon Chevron up icon
  • If you experience a problem with using or installing Adobe Reader, the contact Adobe directly.
  • To view the errata for the book, see www.packtpub.com/support and view the pages for the title you have.
  • To view your account details or to download a new copy of the book go to www.packtpub.com/account
  • To contact us directly if a problem is not resolved, use www.packtpub.com/contact-us
What eBook formats do Packt support? Chevron down icon Chevron up icon

Our eBooks are currently available in a variety of formats such as PDF and ePubs. In the future, this may well change with trends and development in technology, but please note that our PDFs are not Adobe eBook Reader format, which has greater restrictions on security.

You will need to use Adobe Reader v9 or later in order to read Packt's PDF eBooks.

What are the benefits of eBooks? Chevron down icon Chevron up icon
  • You can get the information you need immediately
  • You can easily take them with you on a laptop
  • You can download them an unlimited number of times
  • You can print them out
  • They are copy-paste enabled
  • They are searchable
  • There is no password protection
  • They are lower price than print
  • They save resources and space
What is an eBook? Chevron down icon Chevron up icon

Packt eBooks are a complete electronic version of the print edition, available in PDF and ePub formats. Every piece of content down to the page numbering is the same. Because we save the costs of printing and shipping the book to you, we are able to offer eBooks at a lower cost than print editions.

When you have purchased an eBook, simply login to your account and click on the link in Your Download Area. We recommend you saving the file to your hard drive before opening it.

For optimal viewing of our eBooks, we recommend you download and install the free Adobe Reader version 9.

Modal Close icon
Modal Close icon