ترکیب Server و Client Components

تا اینجا هر کامپوننت را جداگانه بررسی کردیم: Server Component برای داده و محتوا، Client Component برای تعامل. اما صفحه‌های واقعی از ترکیب این دو ساخته می‌شوند و سؤال اصلی این است: کدام‌یک را داخل دیگری می‌توان گذاشت؟ جواب این سؤال تعیین می‌کند مرز Client کجا قرار بگیرد و چقدر JavaScript به مرورگر برود. در این درس قواعد ترکیب، الگوی مهم «Client به‌عنوان پوسته با children» و یک نمونه‌ی عملی در DevNotes را می‌بینیم.

قاعده‌ی اصلی: جهت import مهم است

ترکیب این دو نوع کامپوننت دو جهت دارد و آن‌ها برابر نیستند:

حالت مجاز است؟ توضیح
Server Component یک Client Component را import و رندر کند بله رایج‌ترین حالت؛ مثل صفحه‌ای که SearchBox را رندر می‌کند.
Client Component یک Client Component دیگر را import کند بله هر دو در بسته‌ی مرورگر هستند.
Client Component یک Server Component را import کند خیر (عملاً) ماژول import‌شده خودش Client می‌شود. دیگر Server Component نیست.
Client Component یک Server Component را به‌صورت children یا Prop (JSX) دریافت کند بله الگوی ترکیبی اصلی؛ موضوع بقیه‌ی این درس.

ریشه‌ی این تفاوت، همان چیزی است که در درس «Client Components و دستور use client» دیدیم: "use client" درباره‌ی ماژول‌هایی که import می‌شوند است. وقتی یک فایل Client، ماژولی را import می‌کند، آن ماژول وارد گراف مرورگر می‌شود. اما اگر Server Component خودش یک Client Component را رندر کند و Server Component دیگری را داخل آن بگذارد، آن داخلی هنوز در سرور رندر می‌شود؛ چون Client فقط خروجی آماده را به‌عنوان children می‌گیرد.

اشتباه: import کردن Server Component در Client Component

tsx
// components/Panel.tsx
"use client";

import { useState } from "react";
import RelatedPosts from "@/components/RelatedPosts"; // Server Component async

export default function Panel() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button type="button" onClick={() => setOpen(!open)}>نمایش</button>
      {open && <RelatedPosts currentSlug="x" />}
    </>
  );
}

اینجا RelatedPosts داخل فایل Client import شده است، پس همراه Panel به مرورگر می‌رود. چون RelatedPosts تابع async است و لایه‌ی داده را صدا می‌زند، Next.js خطایی درباره‌ی ناسازگاری async در Client Component می‌دهد، و در بهترین حالت هم کد لایه‌ی داده وارد بسته‌ی مرورگر می‌شود. این ساختار اشتباه است.

راه درست: الگوی «پوسته» با children

راه‌حل این است که Client Component فقط پوسته‌ی تعاملی باشد و محتوای سروری را از بیرون، به‌صورت children، بگیرد. والد (که یک Server Component است) آن‌ها را کنار هم می‌گذارد:

tsx
// Server Component (صفحه)
<Collapsible title="نوشته‌های دیگر">
  <RelatedPosts currentSlug={post.slug} />   {/* Server Component داخل پوسته‌ی Client */}
</Collapsible>

اتفاقی که می‌افتد:

  1. Server Component صفحه، RelatedPosts را روی سرور اجرا می‌کند و خروجی آن (نتیجه‌ی رندر) را می‌سازد.
  2. این خروجی به‌عنوان children به Collapsible داده می‌شود؛ Collapsible کد RelatedPosts را اصلاً نمی‌بیند، فقط محل قرارگرفتن آن را.
  3. در مرورگر، Client Component State باز و بسته بودن را مدیریت می‌کند و children را داخل خودش نشان می‌دهد یا پنهان می‌کند.

همین الگو به شما اجازه می‌دهد بخش بزرگی از صفحه را Server نگه دارید و فقط یک «قاب تعاملی» کوچک بسازید.

الگوی دوم: چند «اسلات» با Props

اگر Client Component به چند محتوای سروری جدا نیاز دارد، می‌توان هرکدام را از طریق یک Prop (از نوع JSX) داد، نه فقط children:

tsx
// نمونه‌ی مفهومی
<SplitView
  sidebar={<CategoryList />}   // Server Component
  content={<PostList />}       // Server Component
/>

SplitView می‌تواند Client Component باشد (مثلاً برای اندازه‌ی قابل‌تغییر پنل‌ها)، در حالی که CategoryList و PostList کد سروری می‌مانند.

عملی: بخش «نوشته‌های دیگر» در صفحه‌ی نوشته

در صفحه‌ی جزئیات نوشته می‌خواهیم بخشی داشته باشیم که فهرست چند نوشته‌ی دیگر را نشان بدهد و کاربر بتواند آن را باز و بسته کند. فهرست از لایه‌ی داده می‌آید (Server Component) و باز و بسته‌شدن به State نیاز دارد (Client Component).

فایل‌های جدید: components/Collapsible.tsx و components/RelatedPosts.tsx. فایل متأثر: app/(public)/posts/[slug]/page.tsx.

گام ۱: پوسته‌ی Client

tsx
// components/Collapsible.tsx
"use client";

import { useId, useState, type ReactNode } from "react";

type CollapsibleProps = {
  title: string;
  defaultOpen?: boolean;
  children: ReactNode;
};

export default function Collapsible({
  title,
  defaultOpen = false,
  children,
}: CollapsibleProps) {
  const [open, setOpen] = useState(defaultOpen);
  const panelId = useId();

  return (
    <section>
      <h2>
        <button
          type="button"
          aria-expanded={open}
          aria-controls={panelId}
          onClick={() => setOpen((prev) => !prev)}
        >
          {title}
        </button>
      </h2>
      <div id={panelId} hidden={!open}>
        {children}
      </div>
    </section>
  );
}
  • children: ReactNode هر چیزی را که React می‌تواند رندر کند می‌پذیرد، از جمله خروجی یک Server Component.
  • Props شامل رشته، Boolean و children است؛ همه قابل ارسال از Server.
  • وضعیت باز یا بسته در State نگه داشته می‌شود و فقط ویژگی hidden تغییر می‌کند؛ خود محتوا همیشه در DOM هست.
  • useId یک شناسه‌ی یکتا و پایدار می‌سازد که روی سرور و مرورگر یکسان است؛ برای پیوند aria-controls با پنل استفاده شده است.
  • دکمه داخل h2 قرار گرفته تا هم تیتر معنایی داشته باشیم و هم کنترل قابل فوکوس با صفحه‌کلید.

گام ۲: محتوای سروری

tsx
// components/RelatedPosts.tsx
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";

type RelatedPostsProps = {
  currentSlug: string;
  count?: number;
};

export default async function RelatedPosts({
  currentSlug,
  count = 3,
}: RelatedPostsProps) {
  const posts = await getAllPosts();
  const related = posts
    .filter((post) => post.slug !== currentSlug)
    .slice(0, count);

  if (related.length === 0) {
    return <p>نوشته‌ی دیگری وجود ندارد.</p>;
  }

  return (
    <ul>
      {related.map((post) => (
        <li key={post.slug}>
          <Link href={`/posts/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}
  • این یک Server Component async است و داده را مستقیم از لایه‌ی داده می‌خواند.
  • نوشته‌ی جاری را از فهرست حذف می‌کند تا کاربر به همان صفحه لینک نشود.
  • برای فهرست خالی هم پیام در نظر گرفته شده است.

گام ۳: ترکیب در صفحه

صفحه Server Component می‌ماند و هر دو را کنار هم می‌گذارد. کد کامل فایل (شامل کامپوننت‌هایی که در درس‌های قبل اضافه شدند):

tsx
// app/(public)/posts/[slug]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import BackButton from "@/components/BackButton";
import Collapsible from "@/components/Collapsible";
import CopyLinkButton from "@/components/CopyLinkButton";
import ReadingProgress from "@/components/ReadingProgress";
import RelatedPosts from "@/components/RelatedPosts";
import { formatDate } from "@/lib/format";
import { getAllPosts, getPostBySlug } from "@/lib/posts";

type PostPageProps = {
  params: Promise<{ slug: string }>;
};

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) {
    notFound();
  }

  return (
    <main>
      <ReadingProgress />
      <BackButton />
      <article>
        <h1>{post.title}</h1>
        <p>
          <time dateTime={post.publishedAt}>
            {formatDate(post.publishedAt)}
          </time>
        </p>
        <p>{post.body}</p>
        <CopyLinkButton path={`/posts/${post.slug}`} />
      </article>

      <Collapsible title="نوشته‌های دیگر">
        <RelatedPosts currentSlug={post.slug} />
      </Collapsible>

      <p>
        <Link href="/posts">همه‌ی نوشته‌ها</Link>
      </p>
    </main>
  );
}

توجه کنید که page.tsx هیچ "use client"ی ندارد و async است. Collapsible و RelatedPosts را فقط «کنار هم» می‌گذارد.

آزمایش: ببینیم کد کجا اجرا می‌شود

  1. در ابتدای تابع RelatedPosts خط console.log("RelatedPosts اجرا شد"); را موقتاً اضافه کنید.
  2. صفحه‌ی /posts/hello-nextjs را ریفرش کنید.
  3. ترمینال را نگاه کنید و بعد کنسول مرورگر را.
  4. در «View Page Source» دنبال عنوان نوشته‌های دیگر بگردید.

نتیجه‌ی مورد انتظار

  • پیام فقط در ترمینال دیده می‌شود، نه در کنسول مرورگر؛ RelatedPosts فقط روی سرور اجرا شده است.
  • پیام ترمینال حتی وقتی بخش بسته است چاپ می‌شود، چون سرور خروجی را از قبل می‌سازد و به Collapsible می‌دهد. بستن یا باز کردن پنل در مرورگر اثری روی اجرای کد سرور ندارد.
  • در سورس صفحه عنوان‌ها هستند (داخل div با ویژگی hidden)، یعنی محتوا در HTML اولیه آمده و فقط پنهان است.
  • با کلیک روی «نوشته‌های دیگر» پنل بدون هیچ درخواست جدیدی به سرور باز می‌شود، چون محتوا از قبل آماده بود.

بعد از آزمایش، خط console.log را حذف کنید.

الگوی دیگر: Provider در Layout

الگوی «پوسته با children» برای Context Providerها هم به کار می‌رود. Providerها به State و Context نیاز دارند، پس Client Component‌اند؛ اما آن‌ها را در Root Layout طوری می‌گذاریم که children را بگیرند، تا خود Layout و بقیه‌ی صفحه Server بمانند:

tsx
// نمونه‌ی مفهومی؛ در DevNotes فعلاً Context نداریم
<body>
  <ThemeProvider>{children}</ThemeProvider>   {/* ThemeProvider یک Client Component است */}
</body>

جزئیات این الگو و محدودیت‌های Context در درس «server-only، Context و اشتباهات رایج» می‌آید.

نکته‌های مهم

  • مرز Client را تا جای ممکن پایین ببرید. به‌جای Client‌کردن کل بخش، یک پوسته‌ی کوچک بسازید و محتوا را از بیرون به آن بدهید.
  • children قبلاً رندر شده است. Client Component نمی‌تواند در رندر محتوای children دخالت کند یا آن را با Props جدید دوباره بسازد؛ فقط محل و نمایش آن را کنترل می‌کند.
  • محتوای پنهان همچنان در پاسخ است. بخشی که با hidden یا شرط در رابط پنهان شده، داده‌اش در HTML یا RSC Payload وجود دارد. چیزی که نباید دیده شود (مثل اطلاعات خصوصی) را نباید به این شکل پنهان کرد؛ باید اصلاً به مرورگر نرسد.
  • هزینه‌ی سرور را فراموش نکنید. محتوای سروری که داخل پنل بسته است، هنگام رندر صفحه اجرا می‌شود. اگر کار سنگینی انجام می‌دهد و بیشتر کاربران هرگز باز نمی‌کنند، می‌توان آن را در مسیر دیگر یا با دریافت دیرهنگام (Streaming و Suspense) پیاده کرد؛ موضوع فصل «Loading، Error و Streaming».
  • ماژول مشترک بین هر دو نوع: فایلی بدون "use client" که هم Server و هم Client import می‌کنند (مثل formatDate) فقط باید کد خالص و بدون وابستگی به سرور یا مرورگر باشد. وقتی Client آن را import می‌کند، همراهش به مرورگر می‌رود.

جمع‌بندی

  • Server Component می‌تواند Client Component را import و رندر کند؛ اما Client Component نمی‌تواند Server Component را import کند بدون اینکه آن به Client تبدیل شود.
  • الگوی درست برای قراردادن محتوای سروری داخل بخش تعاملی، گرفتن آن به‌صورت children یا Prop از نوع JSX است.
  • محتوای children قبل از رسیدن به Client روی سرور رندر شده است و کدش به مرورگر نمی‌رود، حتی اگر در رابط پنهان باشد.
  • در DevNotes، Collapsible پوسته‌ی Client و RelatedPosts محتوای Server است و صفحه‌ی نوشته آن‌ها را ترکیب می‌کند.
  • در درس بعد، «server-only، Context و اشتباهات رایج»، یاد می‌گیریم چطور از رسیدن کد حساس سرور به مرورگر جلوگیری کنیم و Context را در معماری Server/Client درست به کار ببریم.