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");
}
תשובות לשאלות¶
-
שליפה ב-Server Component לעומת useEffect: ב-Server Component הנתונים נשלפים בשרת לפני שה-HTML נשלח, כך שהמשתמש מקבל דף מוכן עם תוכן. עם useEffect הנתונים נשלפים בדפדפן אחרי הרינדור הראשון, כך שהמשתמש רואה מצב טעינה תחילה. בנוסף, Server Component תומך ב-SEO כי התוכן מגיע ב-HTML.
-
no-store לעומת revalidate:
no-storeמתאים לנתונים שחייבים להיות עדכניים בכל רגע (מחירי מניות, סטטוס הזמנה).revalidateמתאים לנתונים שמשתנים אבל מספיק לעדכן כל כמה שניות/דקות (פוסטים בבלוג, רשימת מוצרים). revalidate נותן ביצועים טובים יותר כי משתמש בקאש. -
יתרון Server Actions על API Routes: Server Actions מוגדרים כפונקציות רגילות ולא צריך לנהל HTTP methods, parsers ו-routing. הם type-safe - TypeScript מבין את הקלט והפלט. הם תומכים ב-Progressive Enhancement (עובדים בלי JS). ו-revalidation משולב באופן טבעי.
-
מה Suspense עושה בסטרימינג: Suspense מאפשר לנקסט לשלוח HTML חלקי לדפדפן. הדפדפן מקבל את ה-fallback תחילה, ואז כשהנתונים מוכנים, השרת שולח את ה-HTML הסופי שמחליף את ה-fallback. זה קורה ב-streaming - בלי בקשות נוספות מהדפדפן.
-
Server Actions ו-Progressive Enhancement: כשמשתמשים ב-form action עם Server Action, הטופס נשלח כ-HTML form submit רגיל. גם אם JavaScript מושבת בדפדפן, הטופס עדיין עובד כי הדפדפן שולח POST request לשרת. כש-JS מופעל, נקסט משדרג את החוויה עם טעינה ללא ריענון דף.