לדלג לתוכן

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
// this is a Server Component - runs only on the server
export default async function UsersPage() {
  // can read directly from the database
  const users = await db.user.findMany();

  // can access sensitive environment variables
  const apiKey = process.env.SECRET_API_KEY;

  // console.log will appear in the server's terminal, not in the browser
  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 וכו')
  • להאזין לevents (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)
  • להאזין לevents (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>
      {/* interactive chart */}
    </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: "ישראל",          // string
    age: 25,                // number
    isAdmin: true,          // boolean
    tags: ["react", "next"], // array
    address: {              // object
      city: "תל אביב",
    },
    createdAt: new Date(),  // Date
  };

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

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

// Server Component - this won't work!
export default function Page() {
  const handleClick = () => console.log("clicked");
  const myClass = new MyClass();
  const myMap = new Map();

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

יתרונות Server Components

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

// Server Component - this code is not sent to the browser
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 - direct access to the database
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 - the HTML is ready with all the content
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"

// this will fail! useState is not available in a Server Component
import { useState } from "react";

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

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

// not recommended - you lose the benefits of Server Components
"use client";

export default function Page() {
  // there are no hooks or events here - no reason for this to be a Client Component
  return <h1>שלום</h1>;
}

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

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

import ServerComponent from "./ServerComponent"; // this automatically becomes a Client Component!

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

export default function Layout() {
  return (
    <ClientWrapper>
      <ServerComponent /> {/* this will still run on the server */}
    </ClientWrapper>
  );
}

סיכום

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