לדלג לתוכן

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

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

בשיעור זה נלמד איך לשלוף נתונים ב-Next.js ואיך להשתמש ב-Server Actions כדי לבצע פעולות כתיבה מהקליינט לשרת, בלי ליצור API routes.


שליפת נתונים ב-Server Components

ב-App Router, אפשר לשלוף נתונים ישירות בקומפוננטה באמצעות async/await:

// app/posts/page.tsx
interface Post {
  id: number;
  title: string;
  body: string;
}

export default async function PostsPage() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts");
  const posts: Post[] = await res.json();

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">פוסטים</h1>
      <ul className="space-y-4">
        {posts.map((post) => (
          <li key={post.id} className="border p-4 rounded">
            <h2 className="text-xl font-semibold">{post.title}</h2>
            <p className="text-gray-600 mt-2">{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}
  • אין צורך ב-useEffect או useState
  • הנתונים נשלפים בשרת לפני שה-HTML נשלח לדפדפן
  • מנועי חיפוש מקבלים HTML מוכן עם כל הנתונים

גישה ישירה לבסיס נתונים

אפשר גם לגשת ישירות לבסיס נתונים:

// app/users/page.tsx
import { prisma } from "@/lib/prisma";

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

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">משתמשים</h1>
      {users.map((user) => (
        <div key={user.id} className="border p-4 rounded mb-4">
          <h2 className="text-xl font-semibold">{user.name}</h2>
          <p className="text-gray-600">{user.email}</p>
          <p className="mt-2">{user.posts.length} פוסטים אחרונים</p>
        </div>
      ))}
    </div>
  );
}

שליטה בקאשינג - Caching with fetch

נקסט מרחיב את ה-fetch API עם אפשרויות קאשינג:

ללא קאש - נתונים דינמיים

const res = await fetch("https://api.example.com/data", {
  cache: "no-store",
});
  • הנתונים נשלפים מחדש בכל בקשה
  • מתאים לנתונים שמשתנים תדיר (מחירי מניות, התראות)

קאש סטטי - ברירת מחדל

const res = await fetch("https://api.example.com/data", {
  cache: "force-cache",
});
  • הנתונים נשמרים ומוגשים מהקאש
  • מתאים לנתונים שלא משתנים (תוכן סטטי)

קאש עם עדכון מחזורי - Revalidation

// עדכון לפי זמן (ISR)
const res = await fetch("https://api.example.com/data", {
  next: { revalidate: 60 }, // עדכון כל 60 שניות
});
// עדכון לפי תגית
const res = await fetch("https://api.example.com/posts", {
  next: { tags: ["posts"] },
});
  • revalidate מגדיר כל כמה שניות הנתונים מתעדכנים
  • tags מאפשר עדכון ידני על ידי תגית

סרבר אקשנס - Server Actions

Server Actions הם פונקציות שרצות בשרת אבל אפשר לקרוא להן מהקליינט:

// app/actions.ts
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await prisma.post.create({
    data: { title, content },
  });

  revalidatePath("/posts");
}
  • ההנחיה "use server" בתחילת הקובץ מגדירה שכל הפונקציות בקובץ הן Server Actions
  • הפונקציות רצות בשרת גם כשנקראות מהקליינט
  • revalidatePath מרענן את הקאש של נתיב ספציפי

שימוש ב-Server Actions עם טפסים

// app/posts/new/page.tsx
import { createPost } from "@/app/actions";

export default function NewPostPage() {
  return (
    <div className="max-w-lg mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">פוסט חדש</h1>

      <form action={createPost} className="space-y-4">
        <div>
          <label className="block mb-1 font-semibold">כותרת</label>
          <input
            name="title"
            required
            className="w-full border rounded px-3 py-2"
          />
        </div>
        <div>
          <label className="block mb-1 font-semibold">תוכן</label>
          <textarea
            name="content"
            required
            rows={5}
            className="w-full border rounded px-3 py-2"
          />
        </div>
        <button
          type="submit"
          className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
        >
          פרסם
        </button>
      </form>
    </div>
  );
}
  • מעבירים את ה-Server Action ל-action של הטופס
  • כשהטופס נשלח, הפונקציה רצה בשרת
  • הדפדפן שולח את הנתונים כ-FormData
  • זה עובד גם בלי JavaScript מופעל בדפדפן (Progressive Enhancement)

שימוש ב-Server Actions מ-Client Components

// app/posts/LikeButton.tsx
"use client";

import { useState } from "react";
import { likePost } from "@/app/actions";

export default function LikeButton({ postId, initialLikes }: {
  postId: number;
  initialLikes: number;
}) {
  const [likes, setLikes] = useState(initialLikes);
  const [isLoading, setIsLoading] = useState(false);

  const handleLike = async () => {
    setIsLoading(true);
    try {
      const newLikes = await likePost(postId);
      setLikes(newLikes);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <button
      onClick={handleLike}
      disabled={isLoading}
      className="flex items-center gap-2 px-3 py-1 border rounded hover:bg-gray-50"
    >
      {isLoading ? "..." : `לייק (${likes})`}
    </button>
  );
}
// app/actions.ts
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function likePost(postId: number): Promise<number> {
  const post = await prisma.post.update({
    where: { id: postId },
    data: { likes: { increment: 1 } },
  });

  revalidatePath("/posts");
  return post.likes;
}

עדכון קאש - Revalidation

עדכון לפי נתיב - revalidatePath

"use server";

import { revalidatePath } from "next/cache";

export async function updatePost(id: number, data: { title: string }) {
  await prisma.post.update({ where: { id }, data });

  // מרענן את הקאש של הנתיב הספציפי
  revalidatePath("/posts");

  // אפשר גם לרענן נתיב דינמי ספציפי
  revalidatePath(`/posts/${id}`);

  // או לרענן את כל הלייאאוט
  revalidatePath("/posts", "layout");
}

עדכון לפי תגית - revalidateTag

// שליפה עם תגית
const res = await fetch("https://api.example.com/posts", {
  next: { tags: ["posts"] },
});

// עדכון לפי תגית
"use server";
import { revalidateTag } from "next/cache";

export async function refreshPosts() {
  revalidateTag("posts");
}

ממשק טעינה וסטרימינג - Loading UI and Streaming

קובץ loading.tsx

// app/posts/loading.tsx
export default function PostsLoading() {
  return (
    <div className="p-6 space-y-4">
      {[1, 2, 3].map((i) => (
        <div key={i} className="animate-pulse border p-4 rounded">
          <div className="h-6 bg-gray-200 rounded w-2/3 mb-2" />
          <div className="h-4 bg-gray-200 rounded w-full" />
          <div className="h-4 bg-gray-200 rounded w-4/5 mt-1" />
        </div>
      ))}
    </div>
  );
}

סטרימינג עם Suspense

אפשר לסטרים חלקים שונים של הדף בנפרד:

// app/dashboard/page.tsx
import { Suspense } from "react";
import RevenueChart from "./RevenueChart";
import LatestOrders from "./LatestOrders";
import Stats from "./Stats";

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

      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>

      <div className="grid grid-cols-2 gap-6 mt-6">
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>

        <Suspense fallback={<OrdersSkeleton />}>
          <LatestOrders />
        </Suspense>
      </div>
    </div>
  );
}

function StatsSkeleton() {
  return <div className="h-24 bg-gray-100 rounded animate-pulse" />;
}

function ChartSkeleton() {
  return <div className="h-64 bg-gray-100 rounded animate-pulse" />;
}

function OrdersSkeleton() {
  return <div className="h-64 bg-gray-100 rounded animate-pulse" />;
}
// app/dashboard/Stats.tsx
import { prisma } from "@/lib/prisma";

export default async function Stats() {
  const [users, orders, revenue] = await Promise.all([
    prisma.user.count(),
    prisma.order.count(),
    prisma.order.aggregate({ _sum: { total: true } }),
  ]);

  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="bg-blue-50 p-4 rounded">
        <div className="text-3xl font-bold">{users}</div>
        <div>משתמשים</div>
      </div>
      <div className="bg-green-50 p-4 rounded">
        <div className="text-3xl font-bold">{orders}</div>
        <div>הזמנות</div>
      </div>
      <div className="bg-yellow-50 p-4 rounded">
        <div className="text-3xl font-bold">{revenue._sum.total} ש"ח</div>
        <div>הכנסות</div>
      </div>
    </div>
  );
}
  • כל קומפוננטה בתוך Suspense נטענת בנפרד
  • ה-fallback מוצג בזמן שהקומפוננטה נטענת
  • המשתמש רואה חלקים של הדף ברגע שהם מוכנים

דוגמה מלאה - בלוג עם CRUD

// app/actions/posts.ts
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  if (!title || !content) {
    throw new Error("כותרת ותוכן הם שדות חובה");
  }

  await prisma.post.create({
    data: { title, content },
  });

  revalidatePath("/posts");
  redirect("/posts");
}

export async function updatePost(id: number, formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await prisma.post.update({
    where: { id },
    data: { title, content },
  });

  revalidatePath("/posts");
  revalidatePath(`/posts/${id}`);
  redirect(`/posts/${id}`);
}

export async function deletePost(id: number) {
  await prisma.post.delete({
    where: { id },
  });

  revalidatePath("/posts");
  redirect("/posts");
}
// app/posts/page.tsx
import { prisma } from "@/lib/prisma";
import Link from "next/link";

export default async function PostsPage() {
  const posts = await prisma.post.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="max-w-3xl mx-auto p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">פוסטים</h1>
        <Link
          href="/posts/new"
          className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        >
          פוסט חדש
        </Link>
      </div>

      {posts.length === 0 ? (
        <p className="text-gray-500">אין פוסטים עדיין</p>
      ) : (
        <ul className="space-y-4">
          {posts.map((post) => (
            <li key={post.id} className="border p-4 rounded hover:shadow">
              <Link href={`/posts/${post.id}`}>
                <h2 className="text-xl font-semibold hover:text-blue-600">
                  {post.title}
                </h2>
                <p className="text-gray-600 mt-1 line-clamp-2">
                  {post.content}
                </p>
                <time className="text-sm text-gray-400 mt-2 block">
                  {new Date(post.createdAt).toLocaleDateString("he-IL")}
                </time>
              </Link>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
// app/posts/new/page.tsx
import { createPost } from "@/app/actions/posts";

export default function NewPostPage() {
  return (
    <div className="max-w-lg mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">פוסט חדש</h1>
      <form action={createPost} className="space-y-4">
        <div>
          <label className="block mb-1 font-semibold">כותרת</label>
          <input
            name="title"
            required
            className="w-full border rounded px-3 py-2"
          />
        </div>
        <div>
          <label className="block mb-1 font-semibold">תוכן</label>
          <textarea
            name="content"
            required
            rows={8}
            className="w-full border rounded px-3 py-2"
          />
        </div>
        <button
          type="submit"
          className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
        >
          פרסם
        </button>
      </form>
    </div>
  );
}

טיפול בשגיאות ב-Server Actions

"use server";

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

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

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

    if (!content || content.length < 10) {
      return { success: false, error: "התוכן חייב להכיל לפחות 10 תווים" };
    }

    await prisma.post.create({ data: { title, content } });
    revalidatePath("/posts");
    return { success: true };
  } catch (error) {
    return { success: false, error: "שגיאה ביצירת הפוסט" };
  }
}
// שימוש בקליינט עם טיפול בשגיאות
"use client";

import { useState } from "react";
import { createPost } from "@/app/actions/posts";

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

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

  return (
    <form action={handleSubmit} className="space-y-4">
      {error && (
        <div className="bg-red-50 text-red-600 p-3 rounded">{error}</div>
      )}
      <input name="title" required className="w-full border rounded px-3 py-2" />
      <textarea name="content" required className="w-full border rounded px-3 py-2" />
      <button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded">
        פרסם
      </button>
    </form>
  );
}

סיכום

  • ב-Server Components שולפים נתונים עם async/await ישירות בקומפוננטה
  • fetch של נקסט תומך בקאשינג: cache, revalidate, tags
  • Server Actions הם פונקציות שרת שנקראות מהקליינט עם "use server"
  • אפשר להשתמש ב-Server Actions עם form action או מ-Client Components
  • revalidatePath ו-revalidateTag מרעננים את הקאש
  • Suspense מאפשר סטרימינג של חלקים שונים בדף בנפרד
  • Server Actions תומכים ב-Progressive Enhancement (עובדים גם בלי JS)