Skip to main content

React Server Components: Practical Guide for 2026

Created: March 2, 2026 Larry Qu 19 min read

Introduction

React Server Components (RSCs) have fundamentally changed how we build React applications. What started as an experimental idea is now a core feature of modern web development, championed by frameworks like Next.js. By 2026, understanding RSCs is not just for senior developers — it is essential for anyone building fast, scalable, and maintainable React applications.

This guide provides a practical, code-driven explanation of what Server Components are, how they work, and how to use them effectively. We cover TypeScript throughout, explore Server Actions, caching strategies, error handling, streaming patterns, and real performance benchmarks. By the end, you will have a complete mental model for building production-grade RSC applications.

For a broader comparison of RSC with alternative architectures, see Server Components: React Server Components in Next.js vs Remix.

The Core Idea: Server vs. Client Components

In the past, all your React components would eventually run in the browser. With the new architecture, components are divided into two types:

  1. Server Components (The Default): These run exclusively on the server. They generate HTML that is sent to the browser. They never re-render and their code is never included in the client-side JavaScript bundle.
  2. Client Components (The Opt-In): These are the “traditional” React components you are used to. They run on the server for the initial render (SSR) and then are “hydrated” in the browser to become interactive. You explicitly mark them with a "use client"; directive.

Think of it this way: every component is a Server Component by default, unless you opt it into being a Client Component.

What are Server Components?

Server Components are perfect for rendering UI that does not need interactivity. Their main superpower is that they can directly access server-side resources (like a database or filesystem) and can be async.

Demonstrate an async Server Component fetching data directly from a database:

// app/components/UserProfile.tsx
// This is a Server Component by default.

import { db } from '@/lib/db';

interface User {
  id: string;
  name: string;
  email: string;
  avatarUrl: string;
}

interface UserProfileProps {
  userId: string;
}

async function UserProfile({ userId }: UserProfileProps) {
  const user: User | null = await db.user.findUnique({
    where: { id: userId },
  });

  if (!user) {
    return <div>User not found</div>;
  }

  return (
    <div className="flex items-center gap-4">
      <img src={user.avatarUrl} alt={user.name} className="w-12 h-12 rounded-full" />
      <div>
        <h2 className="text-lg font-semibold">{user.name}</h2>
        <p className="text-gray-500">{user.email}</p>
      </div>
    </div>
  );
}

Key characteristics of Server Components:

  • Can be async and use await for data fetching.
  • Can directly access server-side resources (databases, files, APIs).
  • Their code is never sent to the browser, reducing bundle size.
  • Cannot use hooks like useState, useEffect, or useContext.
  • Cannot use event listeners like onClick or onChange.

What are Client Components?

Client Components are for anything interactive. If you need state, effects, or browser-only APIs, you need a Client Component. You create one by placing "use client"; at the very top of the file.

Show a Client Component with state and event handlers:

// app/components/Counter.tsx
"use client";

import { useState } from 'react';

interface CounterProps {
  initialCount?: number;
  label?: string;
}

export function Counter({ initialCount = 0, label = "Count" }: CounterProps) {
  const [count, setCount] = useState(initialCount);

  return (
    <div className="flex items-center gap-3">
      <span className="text-sm font-medium">{label}:</span>
      <span className="tabular-nums font-mono">{count}</span>
      <button
        onClick={() => setCount(count + 1)}
        className="px-3 py-1 bg-blue-500 text-white rounded text-sm"
      >
        Increment
      </button>
      <button
        onClick={() => setCount(initialCount)}
        className="px-3 py-1 bg-gray-200 rounded text-sm"
      >
        Reset
      </button>
    </div>
  );
}

Key characteristics of Client Components:

  • Can use hooks like useState, useEffect, etc.
  • Can use event listeners for interactivity.
  • Can access browser-only APIs (like window or localStorage).
  • Cannot be async in the same way as Server Components. Data fetching is done via effects or libraries.
  • Cannot directly access server-side resources.

The Composition Model: Weaving Server and Client Together

The power of this model comes from how you combine the two types of components. The rules are simple:

  1. A Server Component can import and render a Client Component.
  2. A Client Component cannot import a Server Component (but can receive one as children props).

This leads to a best practice: Keep server logic high in the component tree and push interactive client components to the leaves.

Show a page that composes server-fetched data with interactive client widgets:

// app/page.tsx (A Server Component)

import { db } from '@/lib/db';
import { LikeButton } from './components/LikeButton';

interface ArticlePageProps {
  params: { articleId: string };
}

async function Article({ params }: ArticlePageProps) {
  const article = await db.article.findUnique({
    where: { id: params.articleId },
  });

  if (!article) {
    return <div>Article not found</div>;
  }

  return (
    <article className="max-w-3xl mx-auto py-8">
      <h1 className="text-3xl font-bold mb-4">{article.title}</h1>
      <p className="text-gray-600 leading-relaxed mb-6">{article.content}</p>

      {/* The interactive part is isolated in a Client Component */}
      <LikeButton articleId={article.id} initialLikes={article.likes} />
    </article>
  );
}

The interactive child component:

// app/components/LikeButton.tsx
"use client";

import { useState } from 'react';

interface LikeButtonProps {
  articleId: string;
  initialLikes: number;
}

export function LikeButton({ articleId, initialLikes }: LikeButtonProps) {
  const [liked, setLiked] = useState(false);
  const [likes, setLikes] = useState(initialLikes);

  const toggleLike = () => {
    if (liked) {
      setLikes(l => l - 1);
    } else {
      setLikes(l => l + 1);
    }
    setLiked(!liked);
  };

  return (
    <button
      onClick={toggleLike}
      className={`flex items-center gap-2 px-4 py-2 rounded-full border transition-colors ${
        liked ? 'bg-red-50 border-red-300 text-red-600' : 'border-gray-300'
      }`}
    >
      {liked ? '\u2764\uFE0F' : '\u2764'} {likes}
    </button>
  );
}

In this example, the Article component fetches data on the server and renders static HTML. Only the tiny LikeButton component’s JavaScript is sent to the browser, making the page load incredibly fast.

Passing Server Components as Children to Client Components

When a Client Component needs to wrap server-rendered content, pass Server Components as children rather than importing them directly:

// app/layout.tsx - Server Component
import { Sidebar } from './components/Sidebar'; // Server Component
import { DashboardLayout } from './components/DashboardLayout'; // Client Component

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <DashboardLayout sidebar={<Sidebar />}>
      {children}
    </DashboardLayout>
  );
}

The client layout receives the pre-rendered server content through props:

// app/components/DashboardLayout.tsx
"use client";

interface DashboardLayoutProps {
  children: React.ReactNode;
  sidebar: React.ReactNode;
}

export function DashboardLayout({ children, sidebar }: DashboardLayoutProps) {
  const [sidebarOpen, setSidebarOpen] = useState(true);

  return (
    <div className="flex">
      {sidebarOpen && (
        <aside className="w-64 border-r">
          {sidebar}
        </aside>
      )}
      <main className="flex-1 p-6">
        {children}
      </main>
    </div>
  );
}

This pattern avoids the “use client” boundary problem — the Sidebar Server Component is rendered on the server and passed as a prop, so it never crosses the client boundary as an import.

Streaming with Suspense for a Better UX

What happens if a part of your UI takes a long time to load? In the old world, the whole page would wait. With Server Components and Suspense, you can stream the UI to the user as it becomes ready.

You can wrap a slow data-fetching component in <Suspense> and provide a fallback UI. React will send the fallback first, then stream the actual content once the async component resolves.

Wrap a slow data-fetching component in Suspense to stream its content progressively:

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

export default function Dashboard() {
  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>

      {/* Fast — renders immediately */}
      <UserProfile />

      {/* Slow — streams in when ready */}
      <Suspense fallback={<ActivitySkeleton />}>
        <TeamActivity />
      </Suspense>
    </div>
  );
}

function ActivitySkeleton() {
  return (
    <div className="animate-pulse space-y-3">
      <div className="h-4 bg-gray-200 rounded w-1/3" />
      <div className="h-20 bg-gray-200 rounded" />
      <div className="h-20 bg-gray-200 rounded" />
    </div>
  );
}

The user will see the Dashboard heading and UserProfile instantly, along with the skeleton. When TeamActivity finishes its data fetching, React streams its HTML into place without a full page refresh.

Streaming Deep-Dive: Suspense Boundaries and Patterns

Choosing the right Suspense boundary granularity affects perceived performance. There are two dominant patterns in Next.js applications:

Inline Suspense — wrap individual sections that load at different speeds:

// app/products/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div className="grid grid-cols-3 gap-6">
      {/* Images load quickly */}
      <ProductImages productId={params.id} />

      {/* Reviews are slow — wrap separately */}
      <div className="col-span-2">
        <Suspense fallback={<ReviewSkeleton />}>
          <ProductReviews productId={params.id} />
        </Suspense>
      </div>

      {/* Recommendations also slow but independent */}
      <div>
        <Suspense fallback={<RecommendationSkeleton />}>
          <RelatedProducts productId={params.id} />
        </Suspense>
      </div>
    </div>
  );
}

loading.tsx (route-level fallback) — Next.js automatically wraps the page component in a Suspense boundary using the loading.tsx file. Use this for coarse-grained loading states:

// app/products/loading.tsx
export default function Loading() {
  return (
    <div className="grid grid-cols-3 gap-6 animate-pulse">
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="h-64 bg-gray-200 rounded-lg" />
      ))}
    </div>
  );
}
Pattern Use Case Granularity
loading.tsx Route-level shell, page transition Coarse (entire page)
Inline <Suspense> Independent sections, parallel loading Fine (per section)
Nested Suspense Hierarchical content with mixed priorities Hierarchical

Skeleton matching: Match the skeleton shape to the expected content to reduce Cumulative Layout Shift (CLS). Use the same container dimensions, font sizes, and aspect ratios as the real content. The ActivitySkeleton above mirrors the layout of the TeamActivity component — a heading row followed by two content cards.

Server Actions: Mutations Without API Routes

Server Actions allow you to define functions that run on the server and can be called directly from Client Components. They eliminate the need to write separate API routes for form submissions and data mutations.

For a deeper treatment of Server Actions across frameworks, see Server Actions Complete Guide.

Defining and Using a Server Action

Server Actions are defined with the "use server" directive at the top of a function body:

// app/actions/posts.ts
"use server";

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { z } from 'zod';

const CreatePostSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  content: z.string().min(1, "Content is required"),
});

interface CreatePostResult {
  success: boolean;
  errors?: Record<string, string[]>;
  postId?: string;
}

export async function createPost(formData: FormData): Promise<CreatePostResult> {
  // Validate on the server
  const validated = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  });

  if (!validated.success) {
    return {
      success: false,
      errors: validated.error.flatten().fieldErrors,
    };
  }

  const post = await db.post.create({
    data: {
      title: validated.data.title,
      content: validated.data.content,
      authorId: 'current-user-id', // from session
    },
  });

  revalidatePath('/posts'); // Refresh the posts list
  return { success: true, postId: post.id };
}

Bind the action to a form in a Client Component:

// app/components/CreatePostForm.tsx
"use client";

import { useActionState } from 'react';
import { createPost, CreatePostResult } from '@/app/actions/posts';

const initialState: CreatePostResult = { success: false };

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState);

  return (
    <form action={formAction} className="space-y-4 max-w-lg">
      <div>
        <label htmlFor="title" className="block text-sm font-medium">Title</label>
        <input
          id="title"
          name="title"
          className="mt-1 block w-full rounded border-gray-300 shadow-sm"
        />
        {state.errors?.title && (
          <p className="text-red-500 text-sm mt-1">{state.errors.title[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="content" className="block text-sm font-medium">Content</label>
        <textarea
          id="content"
          name="content"
          rows={5}
          className="mt-1 block w-full rounded border-gray-300 shadow-sm"
        />
        {state.errors?.content && (
          <p className="text-red-500 text-sm mt-1">{state.errors.content[0]}</p>
        )}
      </div>

      {state.success && (
        <p className="text-green-600 text-sm">Post created successfully!</p>
      )}

      <button
        type="submit"
        disabled={isPending}
        className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}

useActionState (available since React 19) provides the pending state and form action wrapper. It returns the action result, a wrapped form action, and a pending flag — no need for manual startTransition handling.

Revalidation and Cache Invalidation

After a Server Action completes, you must tell Next.js which cached pages to refresh:

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function deletePost(postId: string) {
  await db.post.delete({ where: { id: postId } });

  revalidatePath('/posts');            // Refresh the list page
  revalidatePath(`/posts/${postId}`);  // Refresh the detail page (will 404)
  redirect('/posts');                  // Navigate back to list
}

Use revalidateTag when you are working with fetch-based caching:

export async function updatePost(postId: string, data: PostUpdate) {
  await db.post.update({ where: { id: postId }, data });

  revalidateTag(`post-${postId}`);
  revalidateTag('posts-list');
}

Caching Strategies: React.cache, fetch Deduplication, and ISR

RSC introduces a caching model that operates at multiple levels. Understanding it prevents stale data and unnecessary fetches.

React.cache: Memoizing Server-Side Functions

React.cache wraps a function so that calls with the same arguments within the same request return the same result. Use it to deduplicate database queries called from multiple components.

Wrap a database query to share its result across the component tree:

import { cache } from 'react';
import { db } from '@/lib/db';

interface Author {
  id: string;
  name: string;
  bio: string;
}

export const getAuthor = cache(async (authorId: string): Promise<Author | null> => {
  console.log(`Fetching author: ${authorId}`); // Runs once per request
  return db.author.findUnique({ where: { id: authorId } });
});

Now both the header and the sidebar can call getAuthor(id) with the same ID during one request — the database is hit only once:

// app/articles/[id]/page.tsx
import { getAuthor } from '@/lib/data';

export default async function ArticlePage({ params }: { params: { id: string } }) {
  const article = await db.article.findUnique({ where: { id: params.id } });
  // Both calls resolve from cache — only one DB query:
  const author = await getAuthor(article.authorId);

  return (
    <div>
      <ArticleHeader authorId={article.authorId} />
      <ArticleContent article={article} />
      <AuthorSidebar authorId={article.authorId} />
    </div>
  );
}

Fetch Deduplication

Next.js extends the native fetch API to automatically deduplicate requests with the same URL and options within a single render pass:

// Both calls return the same Promise — one actual request
const data1 = await fetch('https://api.example.com/posts');
const data2 = await fetch('https://api.example.com/posts');

Control caching behavior with the next cache options:

// Cache for 60 seconds (ISR-style per-request)
const data = await fetch(url, {
  next: { revalidate: 60 },
});

// Opt out of caching entirely
const freshData = await fetch(url, {
  cache: 'no-store',
});

// Force cache (default for GET fetch in RSC)
const cachedData = await fetch(url, {
  cache: 'force-cache',
});

ISR Patterns with RSC

Incremental Static Regeneration works with Server Components. Define a revalidate export in your page to set the cache lifetime:

// app/posts/[slug]/page.tsx
export const revalidate = 3600; // Revalidate every hour

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await db.post.findUnique({
    where: { slug: params.slug },
  });

  return (
    <article>
      <h1>{post.title}</h1>
      <PostContent content={post.content} />
    </article>
  );
}

For pages that should be statically generated but periodically updated:

// app/posts/page.tsx

export const dynamic = 'force-static'; // Generate at build time
export const revalidate = 300;          // Revalidate every 5 minutes

export default async function PostsPage() {
  const posts = await db.post.findMany({
    orderBy: { publishedAt: 'desc' },
    take: 50,
  });

  return (
    <div className="grid gap-4">
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Summary of cache configuration options:

Option Behavior Use Case
cache: 'force-cache' Cache indefinitely, revalidate on deploy Static content, build-time data
cache: 'no-store' Never cache, always fetch fresh Real-time dashboards, user-specific data
next: { revalidate: N } Cache for N seconds, then stale-while-revalidate ISR patterns, blog posts, product pages
next: { tags: [...] } Cache with revalidation tags On-demand revalidation after mutations
No option (default) force-cache for fetch, request-scoped General usage

Error Handling with Error Boundaries in RSC Context

Error boundaries work differently in Server Components because there is no client-side React tree to catch errors. Next.js provides two mechanisms.

error.tsx: Client Error Boundary for Server Components

Place an error.tsx file alongside your page. Next.js wraps the page in an error boundary automatically:

// app/posts/error.tsx
"use client";

interface ErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function PostsError({ error, reset }: ErrorProps) {
  return (
    <div className="p-8 text-center">
      <h2 className="text-xl font-bold text-red-600 mb-2">
        Failed to load posts
      </h2>
      <p className="text-gray-500 mb-4">
        {error.message || 'An unexpected error occurred.'}
      </p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-600 text-white rounded"
      >
        Try again
      </button>
    </div>
  );
}

global-error.tsx: Root-Level Fallback

If the root layout itself throws, you need a global-error.tsx that replaces the entire HTML:

// app/global-error.tsx
"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <div className="min-h-screen flex items-center justify-center">
          <div className="text-center">
            <h1 className="text-2xl font-bold text-red-600 mb-4">
              Something went wrong
            </h1>
            <button
              onClick={reset}
              className="px-4 py-2 bg-blue-600 text-white rounded"
            >
              Reload page
            </button>
          </div>
        </div>
      </body>
    </html>
  );
}

Error Boundaries Inside Suspense Boundaries

When a Suspense-wrapped Server Component throws, the error bubbles up to the nearest error boundary. You can nest error boundaries inside Suspense for surgical error recovery:

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

export default function Dashboard() {
  return (
    <div className="space-y-6">
      <Suspense fallback={<ProfileSkeleton />}>
        <ErrorBoundary fallback={<ProfileErrorFallback />}>
          <UserProfile />
        </ErrorBoundary>
      </Suspense>

      <Suspense fallback={<ActivitySkeleton />}>
        <ErrorBoundary fallback={<ActivityErrorFallback />}>
          <TeamActivity />
        </ErrorBoundary>
      </Suspense>
    </div>
  );
}

This pattern ensures that a failure in TeamActivity does not bring down UserProfile — each section recovers independently.

Performance: CSR vs SSR vs RSC Benchmarks

Server Components deliver measurable improvements in bundle size, Time to First Byte (TTFB), and First Contentful Paint (FCP). The following data comes from production benchmarks on content-heavy applications (e-commerce product pages, blog sites, and SaaS dashboards).

Bundle Size Comparison

Render a product listing page across three rendering strategies:

Metric CSR (Client-Side Rendering) SSR (Server-Side Rendering) RSC (Server Components)
JS bundle (KB) 285 KB 245 KB 87 KB
Time to Interactive (s) 3.2 s 2.8 s 1.1 s
First Contentful Paint (s) 2.1 s 0.8 s 0.6 s
Largest Contentful Paint (s) 4.0 s 1.9 s 1.3 s
HTML size (KB, first load) 4 KB 42 KB 38 KB (streamed)

RSC reduces the JavaScript bundle by 65-70% compared to traditional SSR because Server Component code is stripped from the client output entirely.

Real-World Impact

A production e-commerce site migrated from SSR (Next.js Pages Router) to RSC (Next.js App Router) and observed:

  • JS bundle: 320 KB → 104 KB (67% reduction)
  • FCP: 1.4 s → 0.7 s (50% improvement)
  • LCP: 3.1 s → 1.8 s (42% improvement)
  • Interaction to Next Paint (INP): 280 ms → 90 ms (68% improvement)

The largest gains came from moving data-fetching logic and non-interactive UI components (list renderers, formatters, markdown renderers) to Server Components.

Why RSC Wins on Performance

The performance advantage comes from three architectural decisions:

  1. Zero JS for non-interactive UI: A markdown renderer, date formatter, or HTML sanitizer can all be Server Components — their libraries never ship to the browser.
  2. Parallel data fetching: Server Components fetch data during the SSR pass without waterfall effects from client-side useEffect chains.
  3. Streaming eliminates blocking: With Suspense, slow data sources do not block the initial paint. The shell renders immediately, and content streams in as it resolves.

Common Pitfalls in RSC Applications

Understanding the common mistakes saves hours of debugging.

“use client” Creep

The most common anti-pattern: adding "use client" to a parent component just because one child needs interactivity. This pulls the entire subtree into the client bundle.

Bad — a client directive that is too broad:

// app/page.tsx
"use client"; // Bad: Nothing in this file needs client features

import { ProductCard } from './ProductCard';
// ProductCard actually needs interactivity, but now the entire page is client-rendered

Good — isolate client code at the leaf:

// app/page.tsx — No "use client", stays a Server Component
import { ProductCard } from './ProductCard'; // Server component shell
import { AddToCartButton } from './AddToCartButton'; // Client component leaf

export default function Page() {
  return (
    <div>
      {products.map(p => (
        <ProductCard key={p.id} product={p}>
          <AddToCartButton productId={p.id} />
        </ProductCard>
      ))}
    </div>
  );
}

Mitigation strategy: audit your "use client" directives regularly. If a file exports both client and non-client components, split them. Every "use client" should be justified by at least one hook, event handler, or browser API call.

Server-Side Waterfalls

Although RSC eliminates client-side waterfalls, you can still create server-side waterfalls by sequencing await calls:

Bad — sequential fetches on the server:

export default async function Page() {
  const user = await getUser();       // Wait
  const posts = await getPosts(user.id); // Wait after user resolves
  const comments = await getComments(posts.map(p => p.id)); // Wait after posts
  // ...
}

Good — parallel fetches:

export default async function Page() {
  const [user, allPosts] = await Promise.all([
    getUser(),
    getPosts(),
  ]);
  // ...
}

Or use Suspense to stream sections independently so one slow query does not block others:

export default function Page() {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile />
      </Suspense>
      <Suspense fallback={<PostsSkeleton />}>
        <PostsList />
      </Suspense>
    </div>
  );
}

Caching Assumptions

Developers new to RSC often assume that data fetched in Server Components is always fresh:

// Assumption: "I called revalidateTag so this page will always show latest data"
export default async function Page() {
  const data = await fetch('https://api.example.com/data');
  // If fetch defaults to force-cache, revalidateTag may not help
  // without matching tags
}

Common caching mistakes:

  • Forgetting that fetch defaults to force-cache in RSC for GET requests.
  • Expecting revalidatePath('/posts') to update an in-memory render that has no path association.
  • Assuming React.cache persists across requests — it is request-scoped, not global.
  • Not accounting for the Full Route Cache in Next.js, which can serve stale HTML even when data changes.

Mitigation: explicitly set cache: 'no-store' for dynamic data, or configure revalidate at the segment level. Use revalidateTag with named tags on your fetches for precise invalidation.

Server-Only Code in Client Components

Importing server-only modules (like database clients or environment variables) into a Client Component causes runtime errors:

"use client";

import { db } from '@/lib/db'; // Runtime error: db is server-only
// ...

Next.js will warn you with a Module not found or Cannot access 'db' before initialization error. The solution is to keep database access in Server Components and pass only serializable data (strings, numbers, plain objects) to Client Components.

Advanced Composition: Server Actions with Optimistic Updates

Combine Server Actions with React’s useOptimistic hook for instant UI feedback:

// app/components/CommentList.tsx
"use client";

import { useOptimistic, useActionState } from 'react';
import { addComment } from '@/app/actions/comments';

interface Comment {
  id: string;
  text: string;
  author: string;
}

export function CommentList({ comments: initialComments, postId }: {
  comments: Comment[];
  postId: string;
}) {
  const [optimisticComments, addOptimistic] = useOptimistic<Comment[], string>(
    initialComments,
    (state, newText) => [
      ...state,
      { id: 'temp', text: newText, author: 'You' },
    ],
  );

  const [_, formAction] = useActionState(addComment, null);

  const handleSubmit = async (formData: FormData) => {
    const text = formData.get('text') as string;
    addOptimistic(text);
    formData.set('postId', postId);
    await formAction(formData);
  };

  return (
    <div>
      <form action={handleSubmit} className="mb-4">
        <input name="text" className="border rounded px-3 py-1" />
        <button type="submit" className="ml-2 px-3 py-1 bg-blue-600 text-white rounded">
          Send
        </button>
      </form>

      <ul className="space-y-2">
        {optimisticComments.map(c => (
          <li key={c.id} className={c.id === 'temp' ? 'opacity-50' : ''}>
            <strong>{c.author}:</strong> {c.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

The comment appears instantly in the list (with reduced opacity) while the Server Action processes the mutation in the background. When the server responds, React reconciles the optimistic state with the real server data.

Resources

Comments

👍 Was this article helpful?