לדלג לתוכן

9.3 קומפוננטות שרת וקליינט הרצאה

קומפוננטות שרת וקליינט - Server and Client Components

בשיעור זה נלמד את אחד הקונספטים המרכזיים ביותר ב-Next.js App Router - ההבדל בין Server Components ל-Client Components. נבין מתי להשתמש בכל אחד ואיך לשלב ביניהם.


קומפוננטות שרת - Server Components

  • ב-App Router, כל הקומפוננטות הן Server Components כברירת מחדל
  • הקוד רץ בשרת ורק ה-HTML הסופי נשלח לדפדפן
  • הקוד של הקומפוננטה לא נכלל ב-JavaScript bundle שנשלח לדפדפן
// app/users/page.tsx
// זו Server Component - רצה בשרת בלבד
export default async function UsersPage() {
  // אפשר לקרוא ישירות מבסיס נתונים
  const users = await db.user.findMany();

  // אפשר לגשת למשתני סביבה רגישים
  const apiKey = process.env.SECRET_API_KEY;

  // console.log יופיע בטרמינל של השרת, לא בדפדפן
  console.log("רנדור בשרת");

  return (
    <div>
      <h1>משתמשים</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

מה אפשר לעשות ב-Server Components

  • לגשת ישירות לבסיס נתונים
  • לקרוא קבצים מהמערכת (fs)
  • לגשת למשתני סביבה רגישים
  • לשמור מידע רגיש (מפתחות API, טוקנים)
  • לבצע fetch לנתונים בלי useEffect
  • להשתמש ב-async/await ברמת הקומפוננטה

מה אי אפשר לעשות ב-Server Components

  • להשתמש ב-hooks של React (useState, useEffect, useRef וכו')
  • להאזין לאירועים (onClick, onChange וכו')
  • להשתמש ב-Browser APIs (window, document, localStorage)
  • להשתמש ב-Context (useContext)

קומפוננטות קליינט - Client Components

כשצריך אינטראקטיביות, משתמשים בהנחיית "use client":

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

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>ספירה: {count}</p>
      <button onClick={() => setCount(count + 1)}>הוסף</button>
      <button onClick={() => setCount(count - 1)}>הורד</button>
    </div>
  );
}
  • ההנחיה "use client" חייבת להיות בשורה הראשונה של הקובץ
  • היא מגדירה גבול (boundary) - כל מה שמיובא לקובץ הזה הופך לקוד קליינט
  • הקומפוננטה עדיין מקבלת pre-render בשרת (SSR) ואז מתבצע hydration בדפדפן

מה אפשר לעשות ב-Client Components

  • להשתמש ב-hooks (useState, useEffect, useRef, useContext)
  • להאזין לאירועים (onClick, onChange, onSubmit)
  • להשתמש ב-Browser APIs (window, document, localStorage)
  • להשתמש בספריות צד שלישי שדורשות קליינט

מה אי אפשר לעשות ב-Client Components

  • לגשת ישירות לבסיס נתונים
  • לקרוא קבצים מהמערכת
  • לגשת למשתני סביבה רגישים (רק NEXT_PUBLIC_ זמינים)
  • להשתמש ב-async/await ברמת הקומפוננטה

מתי להשתמש בכל סוג

צורך סוג הקומפוננטה
שליפת נתונים Server
גישה לבסיס נתונים Server
גישה למשתני סביבה רגישים Server
תצוגת HTML סטטית Server
אינטראקטיביות (קליקים, טפסים) Client
ניהול state (useState) Client
אפקטים (useEffect) Client
שימוש ב-Browser APIs Client
ספריות צד שלישי עם hooks Client

הכלל הזהב: השתמש ב-Server Component כברירת מחדל, ועבור ל-Client Component רק כשצריך אינטראקטיביות.


תבניות שילוב - Composition Patterns

שרת עוטף קליינט

התבנית הנפוצה ביותר - Server Component שמכיל Client Components:

// app/dashboard/page.tsx (Server Component)
import UserInfo from "./UserInfo";
import InteractiveChart from "./InteractiveChart";

export default async function DashboardPage() {
  const data = await fetchDashboardData();

  return (
    <div>
      <h1>דשבורד</h1>
      <UserInfo user={data.user} />
      <InteractiveChart initialData={data.chart} />
    </div>
  );
}
// app/dashboard/InteractiveChart.tsx (Client Component)
"use client";

import { useState } from "react";

interface ChartProps {
  initialData: number[];
}

export default function InteractiveChart({ initialData }: ChartProps) {
  const [range, setRange] = useState("week");

  return (
    <div>
      <select value={range} onChange={(e) => setRange(e.target.value)}>
        <option value="week">שבוע</option>
        <option value="month">חודש</option>
        <option value="year">שנה</option>
      </select>
      {/* גרף אינטראקטיבי */}
    </div>
  );
}
  • הדף שולף נתונים בשרת ומעביר אותם ל-Client Component דרך props
  • ה-Client Component מנהל את האינטראקטיביות

העברת Server Component כילד

אפשר להעביר Server Components כ-children ל-Client Component:

// app/providers.tsx (Client Component)
"use client";

import { ThemeProvider } from "next-themes";

export default function Providers({ children }: { children: React.ReactNode }) {
  return <ThemeProvider attribute="class">{children}</ThemeProvider>;
}
// app/layout.tsx (Server Component)
import Providers from "./providers";
import ServerContent from "./ServerContent";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="he" dir="rtl">
      <body>
        <Providers>
          <ServerContent />
          {children}
        </Providers>
      </body>
    </html>
  );
}
  • למרות ש-Providers הוא Client Component, ה-children שלו יכולים להיות Server Components
  • זה עובד כי ה-children מרונדרים בשרת ומועברים כ-HTML מוכן

הפרדת קומפוננטה לשני חלקים

כשקומפוננטה צריכה גם נתונים מהשרת וגם אינטראקטיביות:

// app/products/page.tsx (Server Component)
import ProductList from "./ProductList";

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

  return (
    <div>
      <h1>מוצרים</h1>
      <ProductList products={products} />
    </div>
  );
}
// app/products/ProductList.tsx (Client Component)
"use client";

import { useState } from "react";

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

export default function ProductList({ products }: { products: Product[] }) {
  const [filter, setFilter] = useState("");
  const [sortBy, setSortBy] = useState<"name" | "price">("name");

  const filtered = products
    .filter((p) => p.name.includes(filter) || p.category.includes(filter))
    .sort((a, b) => (sortBy === "name" ? a.name.localeCompare(b.name) : a.price - b.price));

  return (
    <div>
      <div className="flex gap-4 mb-4">
        <input
          value={filter}
          onChange={(e) => setFilter(e.target.value)}
          placeholder="סנן מוצרים..."
          className="border rounded px-3 py-1"
        />
        <select value={sortBy} onChange={(e) => setSortBy(e.target.value as "name" | "price")}>
          <option value="name">מיון לפי שם</option>
          <option value="price">מיון לפי מחיר</option>
        </select>
      </div>

      <ul className="space-y-2">
        {filtered.map((product) => (
          <li key={product.id} className="border p-3 rounded">
            <strong>{product.name}</strong> - {product.price} ש"ח
          </li>
        ))}
      </ul>
    </div>
  );
}

מגבלות סריאליזציה - Serialization Constraints

כשמעבירים props מ-Server Component ל-Client Component, הנתונים חייבים להיות ניתנים לסריאליזציה (serializable):

מה אפשר להעביר

// Server Component
export default function Page() {
  const data = {
    name: "ישראל",          // מחרוזת
    age: 25,                // מספר
    isAdmin: true,          // בוליאני
    tags: ["react", "next"], // מערך
    address: {              // אובייקט
      city: "תל אביב",
    },
    createdAt: new Date(),  // Date
  };

  return <ClientComponent data={data} />;
}

מה אי אפשר להעביר

// Server Component - זה לא יעבוד!
export default function Page() {
  const handleClick = () => console.log("clicked");
  const myClass = new MyClass();
  const myMap = new Map();

  return (
    <ClientComponent
      onClick={handleClick}  // פונקציות - לא ניתן לסריאליזציה
      instance={myClass}     // מחלקות - לא ניתן לסריאליזציה
      data={myMap}           // Map/Set - לא ניתן לסריאליזציה
    />
  );
}
  • אי אפשר להעביר פונקציות, מחלקות, Map, Set, Symbol
  • אם צריך פונקציונליות, היא צריכה להיות מוגדרת בתוך ה-Client Component

יתרונות Server Components

חבילה קטנה יותר - Smaller Bundle

// Server Component - הקוד הזה לא נשלח לדפדפן
import { marked } from "marked";      // 35KB
import sanitizeHtml from "sanitize-html"; // 210KB

export default async function BlogPost({ slug }: { slug: string }) {
  const post = await fetchPost(slug);
  const html = sanitizeHtml(marked(post.content));

  return <article dangerouslySetInnerHTML={{ __html: html }} />;
}
  • ספריות כמו marked ו-sanitize-html לא נשלחות לדפדפן
  • המשתמש מוריד פחות JavaScript ומה שהופך את האתר למהיר יותר

גישה ישירה לנתונים - Direct Data Access

// Server Component - גישה ישירה לבסיס נתונים
import { prisma } from "@/lib/prisma";

export default async function UsersPage() {
  const users = await prisma.user.findMany({
    include: { posts: true },
  });

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name} - {user.posts.length} פוסטים
        </li>
      ))}
    </ul>
  );
}
  • אין צורך ב-API route בינוני
  • הנתונים נשלפים ישירות בקומפוננטה

שיפור SEO

// Server Component - ה-HTML מוכן עם כל התוכן
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetchProduct(params.id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>{product.price} ש"ח</span>
    </div>
  );
}
  • מנועי חיפוש מקבלים HTML מלא עם כל התוכן
  • לא צריך לחכות ל-JavaScript שיבנה את הדף

דוגמה מלאה - אפליקציית משימות

// app/todos/page.tsx (Server Component)
import { prisma } from "@/lib/prisma";
import TodoList from "./TodoList";
import AddTodo from "./AddTodo";

export default async function TodosPage() {
  const todos = await prisma.todo.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="max-w-lg mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">המשימות שלי</h1>
      <AddTodo />
      <TodoList initialTodos={todos} />
    </div>
  );
}
// app/todos/AddTodo.tsx (Client Component)
"use client";

import { useState } from "react";

export default function AddTodo() {
  const [title, setTitle] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim()) return;

    await fetch("/api/todos", {
      method: "POST",
      body: JSON.stringify({ title }),
    });

    setTitle("");
    window.location.reload();
  };

  return (
    <form onSubmit={handleSubmit} className="flex gap-2 mb-6">
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="משימה חדשה..."
        className="flex-1 border rounded px-3 py-2"
      />
      <button
        type="submit"
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        הוסף
      </button>
    </form>
  );
}
// app/todos/TodoList.tsx (Client Component)
"use client";

import { useState } from "react";

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

export default function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [todos, setTodos] = useState(initialTodos);
  const [filter, setFilter] = useState<"all" | "active" | "completed">("all");

  const toggleTodo = async (id: number) => {
    const todo = todos.find((t) => t.id === id);
    if (!todo) return;

    await fetch(`/api/todos/${id}`, {
      method: "PATCH",
      body: JSON.stringify({ completed: !todo.completed }),
    });

    setTodos(
      todos.map((t) =>
        t.id === id ? { ...t, completed: !t.completed } : t
      )
    );
  };

  const filtered = todos.filter((todo) => {
    if (filter === "active") return !todo.completed;
    if (filter === "completed") return todo.completed;
    return true;
  });

  return (
    <div>
      <div className="flex gap-2 mb-4">
        <button
          onClick={() => setFilter("all")}
          className={filter === "all" ? "font-bold" : ""}
        >
          הכל
        </button>
        <button
          onClick={() => setFilter("active")}
          className={filter === "active" ? "font-bold" : ""}
        >
          פעיל
        </button>
        <button
          onClick={() => setFilter("completed")}
          className={filter === "completed" ? "font-bold" : ""}
        >
          הושלם
        </button>
      </div>

      <ul className="space-y-2">
        {filtered.map((todo) => (
          <li
            key={todo.id}
            className="flex items-center gap-3 p-3 border rounded"
          >
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span className={todo.completed ? "line-through text-gray-400" : ""}>
              {todo.title}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}
  • הדף הראשי הוא Server Component - שולף נתונים מבסיס הנתונים
  • AddTodo ו-TodoList הם Client Components - צריכים אינטראקטיביות
  • הנתונים הראשוניים מגיעים מהשרת ואז הקליינט מנהל את ה-state

טעויות נפוצות

טעות 1 - שימוש ב-hooks בלי "use client"

// זה ייכשל! useState לא זמין ב-Server Component
import { useState } from "react";

export default function Page() {
  const [count, setCount] = useState(0); // שגיאה!
  return <div>{count}</div>;
}

טעות 2 - הפיכת כל הקומפוננטות ל-Client

// לא מומלץ - מפסידים את היתרונות של Server Components
"use client";

export default function Page() {
  // אין כאן hooks או אירועים - אין סיבה שזה יהיה Client Component
  return <h1>שלום</h1>;
}

טעות 3 - יבוא Server Component לתוך Client Component

// app/ClientWrapper.tsx
"use client";

import ServerComponent from "./ServerComponent"; // זה יהפוך אוטומטית ל-Client!

export default function ClientWrapper() {
  return <ServerComponent />; // לא ירוץ בשרת
}
  • הפתרון: להעביר את ה-Server Component כ-children
// app/layout.tsx (Server)
import ClientWrapper from "./ClientWrapper";
import ServerComponent from "./ServerComponent";

export default function Layout() {
  return (
    <ClientWrapper>
      <ServerComponent /> {/* זה עדיין ירוץ בשרת */}
    </ClientWrapper>
  );
}

סיכום

  • כברירת מחדל כל הקומפוננטות ב-App Router הן Server Components
  • להוספת אינטראקטיביות משתמשים ב-"use client"
  • Server Components מאפשרים גישה ישירה לנתונים, חבילה קטנה יותר ו-SEO טוב
  • Client Components נדרשים ל-hooks, אירועים ו-Browser APIs
  • התבנית המומלצת: Server Component שולף נתונים ומעביר ל-Client Component שמנהל אינטראקטיביות
  • אפשר להעביר Server Components כ-children ל-Client Components
  • נתונים שעוברים מ-Server ל-Client חייבים להיות ניתנים לסריאליזציה