לדלג לתוכן

9.4 שליפת נתונים וסרבר אקשנס פתרון

פתרון - שליפת נתונים וסרבר אקשנס - Data Fetching and Server Actions

פתרון תרגיל 1 - שליפת נתונים מ-API

// app/users/page.tsx
interface User {
  id: number;
  name: string;
  email: string;
  address: { city: string };
  company: { name: string };
}

export default async function UsersPage() {
  const res = await fetch("https://jsonplaceholder.typicode.com/users", {
    next: { revalidate: 3600 },
  });
  const users: User[] = await res.json();

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">משתמשים</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {users.map((user) => (
          <div key={user.id} className="border rounded-lg p-4 shadow-sm hover:shadow-md">
            <h2 className="text-xl font-semibold">{user.name}</h2>
            <p className="text-gray-600 mt-1">{user.email}</p>
            <div className="mt-3 text-sm text-gray-500">
              <p>עיר: {user.address.city}</p>
              <p>חברה: {user.company.name}</p>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

פתרון תרגיל 2 - דף פוסט עם תגובות

// app/posts/[id]/page.tsx
import { Suspense } from "react";
import Comments from "./Comments";

interface Post {
  id: number;
  title: string;
  body: string;
}

export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${params.id}`
  );
  const post: Post = await res.json();

  return (
    <div className="max-w-3xl mx-auto p-6">
      <article>
        <h1 className="text-3xl font-bold mb-4">{post.title}</h1>
        <p className="text-lg leading-relaxed">{post.body}</p>
      </article>

      <hr className="my-8" />

      <h2 className="text-2xl font-bold mb-4">תגובות</h2>
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments postId={params.id} />
      </Suspense>
    </div>
  );
}

function CommentsSkeleton() {
  return (
    <div className="space-y-4">
      {[1, 2, 3].map((i) => (
        <div key={i} className="animate-pulse border p-4 rounded">
          <div className="h-4 bg-gray-200 rounded w-1/4 mb-2" />
          <div className="h-3 bg-gray-200 rounded w-full mb-1" />
          <div className="h-3 bg-gray-200 rounded w-3/4" />
        </div>
      ))}
    </div>
  );
}
// app/posts/[id]/Comments.tsx
interface Comment {
  id: number;
  name: string;
  email: string;
  body: string;
}

export default async function Comments({ postId }: { postId: string }) {
  // השהייה מלאכותית כדי לראות את ה-Suspense
  await new Promise((resolve) => setTimeout(resolve, 1500));

  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${postId}/comments`
  );
  const comments: Comment[] = await res.json();

  return (
    <div className="space-y-4">
      {comments.map((comment) => (
        <div key={comment.id} className="border p-4 rounded">
          <div className="flex justify-between items-center mb-2">
            <strong>{comment.name}</strong>
            <span className="text-sm text-gray-500">{comment.email}</span>
          </div>
          <p className="text-gray-700">{comment.body}</p>
        </div>
      ))}
    </div>
  );
}

פתרון תרגיל 3 - טופס יצירת משימה עם Server Action

// lib/todos.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

let todos: Todo[] = [
  { id: 1, title: "ללמוד Next.js", completed: false },
  { id: 2, title: "לבנות פרויקט", completed: false },
];

let nextId = 3;

export function getTodos(): Todo[] {
  return [...todos];
}

export function addTodo(title: string): Todo {
  const todo: Todo = { id: nextId++, title, completed: false };
  todos.push(todo);
  return todo;
}

export function toggleTodo(id: number): void {
  todos = todos.map((t) =>
    t.id === id ? { ...t, completed: !t.completed } : t
  );
}

export function deleteTodo(id: number): void {
  todos = todos.filter((t) => t.id !== id);
}
// app/actions/todos.ts
"use server";

import { revalidatePath } from "next/cache";
import * as todoDB from "@/lib/todos";

export async function addTodoAction(formData: FormData) {
  const title = formData.get("title") as string;
  if (!title || title.trim().length === 0) return;

  todoDB.addTodo(title.trim());
  revalidatePath("/todos");
}

export async function toggleTodoAction(id: number) {
  todoDB.toggleTodo(id);
  revalidatePath("/todos");
}

export async function deleteTodoAction(id: number) {
  todoDB.deleteTodo(id);
  revalidatePath("/todos");
}
// app/todos/page.tsx
import { getTodos } from "@/lib/todos";
import { addTodoAction } from "@/app/actions/todos";
import TodoItem from "./TodoItem";

export default function TodosPage() {
  const todos = getTodos();

  return (
    <div className="max-w-lg mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">המשימות שלי</h1>

      <form action={addTodoAction} className="flex gap-2 mb-6">
        <input
          name="title"
          required
          placeholder="משימה חדשה..."
          className="flex-1 border rounded px-3 py-2"
        />
        <button
          type="submit"
          className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        >
          הוסף
        </button>
      </form>

      {todos.length === 0 ? (
        <p className="text-gray-500 text-center">אין משימות</p>
      ) : (
        <ul className="space-y-2">
          {todos.map((todo) => (
            <TodoItem key={todo.id} todo={todo} />
          ))}
        </ul>
      )}

      <p className="mt-4 text-sm text-gray-500">
        {todos.filter((t) => t.completed).length} מתוך {todos.length} הושלמו
      </p>
    </div>
  );
}
// app/todos/TodoItem.tsx
"use client";

import { toggleTodoAction, deleteTodoAction } from "@/app/actions/todos";
import type { Todo } from "@/lib/todos";

export default function TodoItem({ todo }: { todo: Todo }) {
  return (
    <li className="flex items-center gap-3 p-3 border rounded">
      <form action={() => toggleTodoAction(todo.id)}>
        <button type="submit">
          <input
            type="checkbox"
            checked={todo.completed}
            readOnly
            className="pointer-events-none"
          />
        </button>
      </form>

      <span
        className={`flex-1 ${
          todo.completed ? "line-through text-gray-400" : ""
        }`}
      >
        {todo.title}
      </span>

      <form action={() => deleteTodoAction(todo.id)}>
        <button
          type="submit"
          className="text-red-500 hover:text-red-700 text-sm"
        >
          מחק
        </button>
      </form>
    </li>
  );
}

פתרון תרגיל 4 - סטרימינג עם Suspense

// app/dashboard/page.tsx
import { Suspense } from "react";
import WeatherWidget from "./WeatherWidget";
import NewsWidget from "./NewsWidget";
import StatsWidget from "./StatsWidget";

export default function DashboardPage() {
  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">דשבורד</h1>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <Suspense fallback={<WidgetSkeleton title="מזג אוויר" />}>
          <WeatherWidget />
        </Suspense>

        <Suspense fallback={<WidgetSkeleton title="חדשות" />}>
          <NewsWidget />
        </Suspense>

        <Suspense fallback={<WidgetSkeleton title="סטטיסטיקות" />}>
          <StatsWidget />
        </Suspense>
      </div>
    </div>
  );
}

function WidgetSkeleton({ title }: { title: string }) {
  return (
    <div className="border rounded-lg p-4 animate-pulse">
      <h2 className="font-bold mb-3">{title}</h2>
      <div className="space-y-2">
        <div className="h-4 bg-gray-200 rounded w-3/4" />
        <div className="h-4 bg-gray-200 rounded w-1/2" />
        <div className="h-4 bg-gray-200 rounded w-2/3" />
      </div>
    </div>
  );
}
// app/dashboard/WeatherWidget.tsx
export default async function WeatherWidget() {
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return (
    <div className="border rounded-lg p-4 bg-blue-50">
      <h2 className="font-bold mb-3">מזג אוויר</h2>
      <div className="text-4xl font-bold">25 מעלות</div>
      <p className="text-gray-600 mt-1">שמשי, תל אביב</p>
    </div>
  );
}
// app/dashboard/NewsWidget.tsx
export default async function NewsWidget() {
  await new Promise((resolve) => setTimeout(resolve, 2000));

  const news = [
    "כותרת חדשות ראשונה",
    "כותרת חדשות שנייה",
    "כותרת חדשות שלישית",
  ];

  return (
    <div className="border rounded-lg p-4 bg-green-50">
      <h2 className="font-bold mb-3">חדשות</h2>
      <ul className="space-y-2">
        {news.map((item, i) => (
          <li key={i} className="text-sm border-b pb-1">
            {item}
          </li>
        ))}
      </ul>
    </div>
  );
}
// app/dashboard/StatsWidget.tsx
export default async function StatsWidget() {
  await new Promise((resolve) => setTimeout(resolve, 3000));

  return (
    <div className="border rounded-lg p-4 bg-yellow-50">
      <h2 className="font-bold mb-3">סטטיסטיקות</h2>
      <div className="space-y-2">
        <div className="flex justify-between">
          <span>מבקרים</span>
          <strong>1,234</strong>
        </div>
        <div className="flex justify-between">
          <span>צפיות</span>
          <strong>5,678</strong>
        </div>
        <div className="flex justify-between">
          <span>המרות</span>
          <strong>89</strong>
        </div>
      </div>
    </div>
  );
}
  • ה-WeatherWidget מופיע אחרי שניה אחת
  • ה-NewsWidget אחרי שתיים
  • ה-StatsWidget אחרי שלוש
  • כל אחד מחליף את ה-skeleton שלו ברגע שהוא מוכן

פתרון תרגיל 5 - טיפול בשגיאות

// app/actions/todos.ts (מעודכן)
"use server";

import { revalidatePath } from "next/cache";
import * as todoDB from "@/lib/todos";

interface ActionResult {
  success: boolean;
  error?: string;
}

export async function addTodoAction(formData: FormData): Promise<ActionResult> {
  const title = formData.get("title") as string;

  if (!title || title.trim().length < 2) {
    return { success: false, error: "המשימה חייבת להכיל לפחות 2 תווים" };
  }

  if (title.trim().length > 100) {
    return { success: false, error: "המשימה לא יכולה להכיל יותר מ-100 תווים" };
  }

  const existing = todoDB.getTodos();
  if (existing.some((t) => t.title === title.trim())) {
    return { success: false, error: "משימה עם שם זהה כבר קיימת" };
  }

  todoDB.addTodo(title.trim());
  revalidatePath("/todos");
  return { success: true };
}
// app/todos/AddTodoForm.tsx
"use client";

import { useState } from "react";
import { useFormStatus } from "react-dom";
import { addTodoAction } from "@/app/actions/todos";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
    >
      {pending ? "מוסיף..." : "הוסף"}
    </button>
  );
}

export default function AddTodoForm() {
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (formData: FormData) => {
    setError(null);
    const result = await addTodoAction(formData);
    if (!result.success) {
      setError(result.error || "שגיאה לא ידועה");
    }
  };

  return (
    <div className="mb-6">
      <form action={handleSubmit} className="flex gap-2">
        <input
          name="title"
          required
          placeholder="משימה חדשה..."
          className="flex-1 border rounded px-3 py-2"
        />
        <SubmitButton />
      </form>
      {error && (
        <p className="text-red-500 text-sm mt-2">{error}</p>
      )}
    </div>
  );
}
  • useFormStatus חייב להיות בקומפוננטה ילד של form
  • לכן SubmitButton הוא קומפוננטה נפרדת
  • pending הוא true בזמן שה-Server Action רץ

פתרון תרגיל 6 - קאשינג ועדכון

// app/caching-demo/page.tsx
import { Suspense } from "react";
import RefreshButton from "./RefreshButton";

async function getStaticTime() {
  const res = await fetch("https://worldtimeapi.org/api/timezone/Asia/Jerusalem", {
    cache: "force-cache",
  });
  const data = await res.json();
  return data.datetime;
}

async function getDynamicTime() {
  const res = await fetch("https://worldtimeapi.org/api/timezone/Asia/Jerusalem", {
    cache: "no-store",
  });
  const data = await res.json();
  return data.datetime;
}

async function getISRTime() {
  const res = await fetch("https://worldtimeapi.org/api/timezone/Asia/Jerusalem", {
    next: { revalidate: 10 },
  });
  const data = await res.json();
  return data.datetime;
}

export default async function CachingDemoPage() {
  const [staticTime, dynamicTime, isrTime] = await Promise.all([
    getStaticTime(),
    getDynamicTime(),
    getISRTime(),
  ]);

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">הדגמת קאשינג</h1>

      <div className="space-y-6">
        <div className="border p-4 rounded bg-blue-50">
          <h2 className="font-bold mb-2">סטטי (force-cache)</h2>
          <p>הזמן לא ישתנה בריענון:</p>
          <code className="block mt-1">{staticTime}</code>
        </div>

        <div className="border p-4 rounded bg-green-50">
          <h2 className="font-bold mb-2">דינמי (no-store)</h2>
          <p>הזמן משתנה בכל ריענון:</p>
          <code className="block mt-1">{dynamicTime}</code>
        </div>

        <div className="border p-4 rounded bg-yellow-50">
          <h2 className="font-bold mb-2">ISR (revalidate: 10)</h2>
          <p>הזמן מתעדכן כל 10 שניות:</p>
          <code className="block mt-1">{isrTime}</code>
        </div>
      </div>

      <div className="mt-6">
        <RefreshButton />
      </div>
    </div>
  );
}
// app/caching-demo/RefreshButton.tsx
"use client";

import { useRouter } from "next/navigation";
import { refreshCache } from "./actions";

export default function RefreshButton() {
  const router = useRouter();

  return (
    <div className="flex gap-2">
      <button
        onClick={() => router.refresh()}
        className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
      >
        ריענון רגיל
      </button>
      <form action={refreshCache}>
        <button
          type="submit"
          className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
        >
          ריענון קאש (revalidatePath)
        </button>
      </form>
    </div>
  );
}
// app/caching-demo/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function refreshCache() {
  revalidatePath("/caching-demo");
}

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

  1. שליפה ב-Server Component לעומת useEffect: ב-Server Component הנתונים נשלפים בשרת לפני שה-HTML נשלח, כך שהמשתמש מקבל דף מוכן עם תוכן. עם useEffect הנתונים נשלפים בדפדפן אחרי הרינדור הראשון, כך שהמשתמש רואה מצב טעינה תחילה. בנוסף, Server Component תומך ב-SEO כי התוכן מגיע ב-HTML.

  2. no-store לעומת revalidate: no-store מתאים לנתונים שחייבים להיות עדכניים בכל רגע (מחירי מניות, סטטוס הזמנה). revalidate מתאים לנתונים שמשתנים אבל מספיק לעדכן כל כמה שניות/דקות (פוסטים בבלוג, רשימת מוצרים). revalidate נותן ביצועים טובים יותר כי משתמש בקאש.

  3. יתרון Server Actions על API Routes: Server Actions מוגדרים כפונקציות רגילות ולא צריך לנהל HTTP methods, parsers ו-routing. הם type-safe - TypeScript מבין את הקלט והפלט. הם תומכים ב-Progressive Enhancement (עובדים בלי JS). ו-revalidation משולב באופן טבעי.

  4. מה Suspense עושה בסטרימינג: Suspense מאפשר לנקסט לשלוח HTML חלקי לדפדפן. הדפדפן מקבל את ה-fallback תחילה, ואז כשהנתונים מוכנים, השרת שולח את ה-HTML הסופי שמחליף את ה-fallback. זה קורה ב-streaming - בלי בקשות נוספות מהדפדפן.

  5. Server Actions ו-Progressive Enhancement: כשמשתמשים ב-form action עם Server Action, הטופס נשלח כ-HTML form submit רגיל. גם אם JavaScript מושבת בדפדפן, הטופס עדיין עובד כי הדפדפן שולח POST request לשרת. כש-JS מופעל, נקסט משדרג את החוויה עם טעינה ללא ריענון דף.