לדלג לתוכן

9.3 קומפוננטות שרת וקליינט פתרון

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

פתרון תרגיל 1 - זיהוי סוג הקומפוננטה

  1. רשימת מוצרים מבסיס נתונים - Server Component - גישה ישירה לבסיס נתונים, אין צורך באינטראקטיביות
  2. טופס הרשמה - Client Component - צריך useState לניהול שדות הקלט ו-onChange
  3. שעון בזמן אמת - Client Component - צריך useEffect עם setInterval ו-useState
  4. קריאת קובץ JSON - Server Component - קריאת קבצים מהשרת, אין אינטראקטיביות
  5. אקורדיון - Client Component - צריך useState למעקב אחרי מצב פתוח/סגור ו-onClick
  6. תוכן Markdown - Server Component - ההמרה קורית בשרת, הספרייה לא נשלחת לדפדפן

מימוש הקומפוננטות:

// 1. ProductList - Server Component
import { prisma } from "@/lib/prisma";

export default async function ProductList() {
  const products = await prisma.product.findMany();

  return (
    <ul className="space-y-2">
      {products.map((product) => (
        <li key={product.id} className="border p-3 rounded">
          <strong>{product.name}</strong> - {product.price} ש"ח
        </li>
      ))}
    </ul>
  );
}
// 2. SignupForm - Client Component
"use client";

import { useState } from "react";

export default function SignupForm() {
  const [form, setForm] = useState({ name: "", email: "", password: "" });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const res = await fetch("/api/signup", {
      method: "POST",
      body: JSON.stringify(form),
    });
    if (res.ok) alert("נרשמת בהצלחה!");
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4 max-w-md">
      <input
        value={form.name}
        onChange={(e) => setForm({ ...form, name: e.target.value })}
        placeholder="שם"
        className="w-full border rounded px-3 py-2"
      />
      <input
        type="email"
        value={form.email}
        onChange={(e) => setForm({ ...form, email: e.target.value })}
        placeholder="אימייל"
        className="w-full border rounded px-3 py-2"
      />
      <input
        type="password"
        value={form.password}
        onChange={(e) => setForm({ ...form, password: e.target.value })}
        placeholder="סיסמה"
        className="w-full border rounded px-3 py-2"
      />
      <button type="submit" className="w-full bg-blue-500 text-white py-2 rounded">
        הרשמה
      </button>
    </form>
  );
}
// 3. Clock - Client Component
"use client";

import { useState, useEffect } from "react";

export default function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const timer = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="text-4xl font-mono">
      {time.toLocaleTimeString("he-IL")}
    </div>
  );
}
// 5. Accordion - Client Component
"use client";

import { useState } from "react";

interface AccordionItem {
  title: string;
  content: string;
}

export default function Accordion({ items }: { items: AccordionItem[] }) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);

  return (
    <div className="space-y-2">
      {items.map((item, index) => (
        <div key={index} className="border rounded">
          <button
            onClick={() => setOpenIndex(openIndex === index ? null : index)}
            className="w-full text-right p-3 font-semibold hover:bg-gray-50"
          >
            {item.title} {openIndex === index ? "-" : "+"}
          </button>
          {openIndex === index && (
            <div className="p-3 border-t bg-gray-50">{item.content}</div>
          )}
        </div>
      ))}
    </div>
  );
}

פתרון תרגיל 2 - הפרדת שרת וקליינט

// app/products/page.tsx (Server Component)
import { prisma } from "@/lib/prisma";
import ProductSearch from "./ProductSearch";

export default async function ProductsPage() {
  const products = await prisma.product.findMany();

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">מוצרים</h1>
      <ProductSearch products={products} />
    </div>
  );
}
// app/products/ProductSearch.tsx (Client Component)
"use client";

import { useState } from "react";

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

export default function ProductSearch({ products }: { products: Product[] }) {
  const [search, setSearch] = useState("");

  const filtered = products.filter((p) =>
    p.name.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="חפש מוצר..."
        className="border rounded px-3 py-2 mb-4 w-full max-w-md"
      />
      <ul className="space-y-2">
        {filtered.map((p) => (
          <li key={p.id} className="border p-3 rounded">
            {p.name} - {p.price} ש
          </li>
        ))}
      </ul>
      <p className="mt-2 text-gray-500">
        מציג {filtered.length} מתוך {products.length} מוצרים
      </p>
    </div>
  );
}
  • הדף (Server Component) שולף נתונים מבסיס הנתונים
  • הקומפוננטה ProductSearch (Client Component) מקבלת את הנתונים ומנהלת חיפוש

פתרון תרגיל 3 - תבנית Provider

// app/context/ThemeContext.tsx
"use client";

import { createContext, useContext, useState } from "react";

type Theme = "light" | "dark";

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | null>(null);

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error("useTheme must be used within ThemeProvider");
  return context;
}

export default function ThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [theme, setTheme] = useState<Theme>("light");

  const toggleTheme = () => {
    setTheme((prev) => (prev === "light" ? "dark" : "light"));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      <div className={theme === "dark" ? "bg-gray-900 text-white" : "bg-white text-black"}>
        {children}
      </div>
    </ThemeContext.Provider>
  );
}
// app/components/ThemeToggle.tsx
"use client";

import { useTheme } from "../context/ThemeContext";

export default function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button
      onClick={toggleTheme}
      className="px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-700"
    >
      {theme === "light" ? "מצב כהה" : "מצב בהיר"}
    </button>
  );
}
// app/layout.tsx (Server Component)
import ThemeProvider from "./context/ThemeContext";
import ThemeToggle from "./components/ThemeToggle";
import "./globals.css";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="he" dir="rtl">
      <body>
        <ThemeProvider>
          <header className="p-4 border-b flex justify-between items-center">
            <h1 className="text-xl font-bold">האתר שלי</h1>
            <ThemeToggle />
          </header>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}
  • ThemeProvider הוא Client Component שעוטף את האפליקציה
  • ה-children (כולל דפי Server Component) מרונדרים בשרת ומועברים כ-HTML
  • ThemeToggle משתמש ב-useTheme כדי לגשת ל-context

פתרון תרגיל 4 - דף פרופיל מורכב

// app/profile/page.tsx (Server Component)
import ProfileHeader from "./ProfileHeader";
import ProfileStats from "./ProfileStats";
import EditProfileForm from "./EditProfileForm";
import ProfileTabs from "./ProfileTabs";

// סימולציה של שליפת נתונים
async function getUser() {
  return {
    id: 1,
    name: "ישראל ישראלי",
    email: "israel@example.com",
    avatar: "/avatar.jpg",
    bio: "מפתח פול-סטאק",
    posts: 42,
    followers: 180,
    following: 95,
  };
}

export default async function ProfilePage() {
  const user = await getUser();

  return (
    <div className="max-w-4xl mx-auto p-6">
      <ProfileHeader name={user.name} avatar={user.avatar} bio={user.bio} />
      <ProfileStats
        posts={user.posts}
        followers={user.followers}
        following={user.following}
      />
      <EditProfileForm
        initialName={user.name}
        initialEmail={user.email}
        initialBio={user.bio}
      />
      <ProfileTabs userId={user.id} />
    </div>
  );
}
// app/profile/ProfileHeader.tsx (Server Component)
interface ProfileHeaderProps {
  name: string;
  avatar: string;
  bio: string;
}

export default function ProfileHeader({ name, avatar, bio }: ProfileHeaderProps) {
  return (
    <div className="flex items-center gap-6 mb-6">
      <div className="w-24 h-24 bg-gray-300 rounded-full flex items-center justify-center text-3xl">
        {name[0]}
      </div>
      <div>
        <h1 className="text-3xl font-bold">{name}</h1>
        <p className="text-gray-600 mt-1">{bio}</p>
      </div>
    </div>
  );
}
// app/profile/ProfileStats.tsx (Server Component)
interface ProfileStatsProps {
  posts: number;
  followers: number;
  following: number;
}

export default function ProfileStats({ posts, followers, following }: ProfileStatsProps) {
  return (
    <div className="flex gap-8 mb-6 p-4 bg-gray-50 rounded">
      <div className="text-center">
        <div className="text-2xl font-bold">{posts}</div>
        <div className="text-gray-600">פוסטים</div>
      </div>
      <div className="text-center">
        <div className="text-2xl font-bold">{followers}</div>
        <div className="text-gray-600">עוקבים</div>
      </div>
      <div className="text-center">
        <div className="text-2xl font-bold">{following}</div>
        <div className="text-gray-600">עוקב</div>
      </div>
    </div>
  );
}
// app/profile/EditProfileForm.tsx (Client Component)
"use client";

import { useState } from "react";

interface EditProfileFormProps {
  initialName: string;
  initialEmail: string;
  initialBio: string;
}

export default function EditProfileForm({
  initialName,
  initialEmail,
  initialBio,
}: EditProfileFormProps) {
  const [isEditing, setIsEditing] = useState(false);
  const [name, setName] = useState(initialName);
  const [email, setEmail] = useState(initialEmail);
  const [bio, setBio] = useState(initialBio);

  const handleSave = async () => {
    await fetch("/api/profile", {
      method: "PUT",
      body: JSON.stringify({ name, email, bio }),
    });
    setIsEditing(false);
  };

  if (!isEditing) {
    return (
      <button
        onClick={() => setIsEditing(true)}
        className="mb-6 px-4 py-2 border rounded hover:bg-gray-50"
      >
        ערוך פרופיל
      </button>
    );
  }

  return (
    <div className="mb-6 p-4 border rounded space-y-3">
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="שם"
        className="w-full border rounded px-3 py-2"
      />
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="אימייל"
        className="w-full border rounded px-3 py-2"
      />
      <textarea
        value={bio}
        onChange={(e) => setBio(e.target.value)}
        placeholder="ביו"
        className="w-full border rounded px-3 py-2"
      />
      <div className="flex gap-2">
        <button
          onClick={handleSave}
          className="px-4 py-2 bg-blue-500 text-white rounded"
        >
          שמור
        </button>
        <button
          onClick={() => setIsEditing(false)}
          className="px-4 py-2 border rounded"
        >
          בטל
        </button>
      </div>
    </div>
  );
}
// app/profile/ProfileTabs.tsx (Client Component)
"use client";

import { useState } from "react";

export default function ProfileTabs({ userId }: { userId: number }) {
  const [activeTab, setActiveTab] = useState<"posts" | "comments" | "likes">("posts");

  const tabs = [
    { key: "posts" as const, label: "פוסטים" },
    { key: "comments" as const, label: "תגובות" },
    { key: "likes" as const, label: "לייקים" },
  ];

  return (
    <div>
      <div className="flex border-b mb-4">
        {tabs.map((tab) => (
          <button
            key={tab.key}
            onClick={() => setActiveTab(tab.key)}
            className={`px-6 py-3 ${
              activeTab === tab.key
                ? "border-b-2 border-blue-500 font-bold"
                : "text-gray-500"
            }`}
          >
            {tab.label}
          </button>
        ))}
      </div>

      <div className="p-4">
        {activeTab === "posts" && <p>הפוסטים של משתמש {userId}</p>}
        {activeTab === "comments" && <p>התגובות של משתמש {userId}</p>}
        {activeTab === "likes" && <p>הלייקים של משתמש {userId}</p>}
      </div>
    </div>
  );
}

פתרון תרגיל 5 - גבולות סריאליזציה

מקרה 1 - פונקציה לא ניתנת לסריאליזציה:

// הפתרון: להעביר את הנתונים ולהגדיר את הפונקציה ב-Client Component
// Server Component
export default function Page() {
  return <PriceDisplay price={100} currency="ש\"ח" />;
}

// Client Component
"use client";
export default function PriceDisplay({ price, currency }: { price: number; currency: string }) {
  const formatPrice = (price: number) => `${price} ${currency}`;
  return <span>{formatPrice(price)}</span>;
}

מקרה 2 - Map לא ניתן לסריאליזציה:

// הפתרון: להמיר ל-object רגיל
export default function Page() {
  const settings = { theme: "dark", lang: "he" };
  return <Settings data={settings} />;
}

מקרה 3 - RegExp לא ניתן לסריאליזציה:

// הפתרון: להעביר כמחרוזת
export default function Page() {
  return <Validator pattern="^[a-z]+$" />;
}

// Client Component
"use client";
export default function Validator({ pattern }: { pattern: string }) {
  const regex = new RegExp(pattern);
  // שימוש ב-regex...
}

תשובות לשאלות

  1. מה קורה כשמייבאים Server Component לתוך Client Component: הקומפוננטה המיובאת הופכת אוטומטית ל-Client Component. הקוד שלה נשלח לדפדפן ולא ירוץ בשרת. הפתרון הוא להעביר את ה-Server Component כ-children.

  2. למה Client Components עוברים SSR: גם Client Components מרונדרים תחילה בשרת כ-HTML כדי שהמשתמש יראה תוכן מיד. אחר כך מתבצע hydration - React מחבר את האירועים וה-state ל-HTML הקיים. זה שונה מ-Server Components שרצים רק בשרת.

  3. ההבדל בין children לעומת יבוא ישיר: כשמעבירים כ-children, ה-Server Component מרונדר בשרת והתוצאה (HTML) מוזרקת ל-Client Component. כשמייבאים ישירות, הקובץ הופך לחלק מה-client bundle ורץ בדפדפן, ומאבד את יתרונות השרת.

  4. איך "use client" משפיע על imports: ההנחיה "use client" יוצרת גבול (boundary). כל קובץ שמיובא לתוך קובץ עם "use client" הופך אוטומטית לקוד קליינט, גם אם אין בו את ההנחיה. לכן חשוב לשים את הגבול במקום הנכון.

  5. האם כל הקוד של Client Component רץ רק בדפדפן: לא. Client Components עוברים SSR בשרת תחילה (לייצור HTML ראשוני) ואז רצים שוב בדפדפן ב-hydration. לכן קוד שמשתמש ב-window או document צריך להיות בתוך useEffect שרץ רק בדפדפן.