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 חייבים להיות ניתנים לסריאליזציה