Server Actions ב-Next.js: טופס שעובד עוד לפני שה-JavaScript נטען
Server Actions ב-Next.js: טופס שעובד עוד לפני שה-JavaScript נטען
הדפוס הקלאסי של טופס ב-React: onSubmit, event.preventDefault(), fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }), useState לטיפול במצב הטעינה. זה עובד – בתנאי אחד שקל לשכוח, כי במחשב שלכם עם חיבור מהיר הוא אף פעם לא מתגלה: ה-JavaScript חייב להספיק להיטען, להתפענח ולהתבצע, לפני שהטופס בכלל מתחיל להגיב ללחיצה. בחיבור סלולרי איטי, באמצע הידרציה של עמוד גדול, או כשהסקריפט נחסם על ידי תוסף דפדפן – המשתמש לוחץ "שלח", ופשוטו כמשמעו שום דבר לא קורה. אין שגיאה, אין ספינר – ה-onClick פשוט עדיין לא קיים.
Server Actions ב-Next.js לפעמים מוצגים כתחביר נוח יותר לאותה קריאה בדיוק – "fetch, רק שלא צריך לכתוב API route". זהו פישוט יתר שמפספס את מה שבאמת חדש במנגנון הזה: <form action={myAction}> נשען על המנגנון הטבעי, הקיים מאז ומתמיד, של שליחת טפסים על ידי הדפדפן, לא על JavaScript כתנאי הכרחי. זה משנה גם איך זה עובד מתחת למכסה המנוע וגם אילו שגיאות בפועל עושים צוותים שמתייחסים ל-"use server" כאל קריאה רגילה לפונקציה מקומית.
מה ההוראה "use server" באמת עושה
כשמסמנים פונקציה "use server", הקומפיילר של Next.js לא משאיר את הגוף שלה בחבילה שהדפדפן מקבל. הוא חותך אותו ומחליף אותו ברפרנס – מזהה מוצפן שמצביע על איזו פונקציה בשרת יש לקרוא לה. מה שבפועל מגיע ללקוח הוא stub קטן: "כשתקרא לי, שלח POST לנקודת קצה מיוחדת של RSC עם המזהה הזה ועם הארגומנטים בסריאליזציה". הלוגיקה עצמה – שאילתה למסד נתונים, אימות, כל מה שהפונקציה עושה – לעולם לא עוזבת פיזית את השרת.
tsx
// app/actions.ts"use server";import { revalidatePath } from "next/cache";import { db } from "@/lib/db";export async function addTodoAction(prevState: unknown, formData: FormData) {const title = formData.get("title");if (typeof title !== "string" || title.trim().length === 0) {return { error: "הכותרת לא יכולה להיות ריקה." };}await db.todo.create({ data: { title: title.trim() } });revalidatePath("/todos");return { error: null };}
כשמחברים את הפונקציה הזו ל-<form action={addTodoAction}>, קורה משהו ש-fetch רגיל אף פעם לא נתן בחינם: זה עובד עוד לפני שכל JavaScript שהוא מתבצע. הדפדפן תמיד ידע לשלוח טופס כ-POST טבעי, אם המאפיין action מצביע ל-URL – React 19 ו-Next.js מרחיבים את זה כך שה-action יכול להיות גם פונקציית שרת, והדפדפן ממילא יבצע את הנתיב הטבעי שלו, אם JS עדיין לא מוכן. כש-JavaScript כבר פועל, Next.js תופס את האירוע הזה ועושה את אותו הדבר דרך fetch ברקע, בלי טעינה מחדש של העמוד, עם עדכון חלק של הממשק. זהו progressive enhancement מובנה במסגרת העבודה, לא משהו שאתם צריכים לתכנת בעצמכם – בדיוק אותו מנגנון שכתבנו עליו בהקשר של RouteAnnouncer במאמר על נגישות, רק שמיושם על שינוי נתונים במקום ניווט.
דוגמה מעשית: טופס עם טיפול מלא במצב, בלי fetch ידני
React 19 הוסיף את useActionState במיוחד בשביל הדפוס הזה – הוא מחבר קריאה לפעולת שרת עם מצב שהוחזר מהביצוע האחרון שלה, בלי useState ידני לשמירת שגיאה או סטטוס טעינה.
tsx
"use client";import { useActionState } from "react";import { addTodoAction } from "./actions";const initialState = { error: null };const TodoForm = () => {const [state, formAction, isPending] = useActionState(addTodoAction,initialState,);return (<form action={formAction}><input type="text" name="title" placeholder="משימה חדשה" required /><button type="submit" disabled={isPending}>{isPending ? "מוסיף…" : "הוסף"}</button>{state.error && <p className="form-error">{state.error}</p>}</form>);};export default TodoForm;
כמה דברים קורים כאן בו-זמנית, בלי שורת קוד אחת המוקדשת לניהול ידני של הבקשה. isPending משקף את מצב השליחה האמיתי של הטופס – כולל אותו POST טבעי, שקדם להידרציה, לא רק קריאות בצד הלקוח. state.error הוא ישירות מה שהחזירה פונקציית השרת – בלי פענוח תשובת JSON, בלי try/catch סביב fetch. revalidatePath("/todos") בפעולה מורה ל-Next.js לרענן את המטמון עבור הנתיב הזה, כך שרשימת המשימות בעמוד תציג את הרשומה החדשה בלי רענון ידני של מצב הלקוח – מסגרת העבודה, לא אתם, מחליטה מתי ואיך למשוך payload חדש של RSC.
מלכודות ושיטות עבודה טובות
- כל פונקציית
"use server"היא נקודת קצה HTTP ציבורית – לא פונקציה פרטית. זו הטעות הנפוצה ביותר בביטחון בקשר ל-Server Actions: מכיוון שכלום ב-UI לא מקשר אליה במפורש, היא נראית "מוסתרת". היא לא – הקומפיילר מייצר עבורה מזהה פעולה קבוע וניתן לקריאה מבחוץ, כך שכל מי שמכיר אותו (למשל בעזרת צפייה בתעבורת רשת ב-DevTools) יכול לקרוא לה ישירות, תוך עקיפת ה-UI שלכם וההנחות שלו. אימות קלט ובדיקת הרשאות (session, תפקיד המשתמש) חייבים להתבצע בתוך הפעולה עצמה, בדיוק כמו ב-API endpoint קלאסי – לעולם אל תסמכו על כך ש"הכפתור בלתי נראה למי שלא מחובר". - הארגומנטים והערך המוחזר חייבים להיות ניתנים לסריאליזציה. Server Actions מעבירים נתונים ברשת בפורמט הסריאליזציה של React (עשיר יותר מ-JSON – תומך בין השאר ב-
Date, Map, FormDataשמועבר ישירות מהטופס), אבל עדיין לא יעבירו פונקציות, מופעי מחלקות, או רפרנסים לאובייקטי DOM. אם אתם משתמשים ב-.bind()כדי לסגור ארגומנט נוסף בקריאה מהלקוח (addTodoAction.bind(null, listId)), זכרו ש-listIdגם צריך להיות ניתן לסריאליזציה. - זו עדיין בקשת רשת אמיתית, לא קריאה מקומית לפונקציה. קל לשכוח את זה, כי תחבירית
await myAction(data)נראה זהה לחלוטין לקריאה לפונקציית JS רגילה. מתחת למכסה המנוע זה תמיד round-trip מלא: סריאליזציה, HTTP, דה-סריאליזציה בצד השני. קריאה ל-Server Action בלולאה אחרי כל תו שמוקלד בשדה טקסט (למשל לצורך אימות שוטף) תהפוך את הטופס שלכם למחולל בקשות, לא לשדה קלט רספונסיבי – למקרים כאלה debounce הכרחי בדיוק כמו בכל קריאת רשת אחרת. - היעדר
revalidatePath/revalidateTagמשאיר את ה-UI עם מטמון לא עדכני. Next.js כברירת מחדל שומר במטמון נתוני עמודים – פעולה ששומרת משהו במסד הנתונים אבל לא אומרת למסגרת העבודה איזה נתיב לרענן, גורמת לתלונה נפוצה של "זה נשמר, אבל לא רואים" – הנתונים תקינים במסד הנתונים, אבל ה-UI עדיין מציג גרסה ישנה של העמוד, מהמטמון.
סיכום
Server Actions הם לא "fetch עם תחביר יפה יותר" – אלה שני דברים שונים, בנויים על אותו מנגנון. ראשית, הם נשענים על שליחת טפסים טבעית של הדפדפן, כך שהטופס עובד אפילו לפני שה-JavaScript מספיק להיטען – אתם לא צריכים לכתוב אפילו שורת קוד אחת האחראית להתנהגות הזו, אתם מקבלים אותה מעצם הארכיטקטורה של <form action={...}>. שנית, וחשוב יותר מבחינת ביטחון: כל פונקציה שמסומנת "use server" הופכת לנקודת קצה אמיתית וניתנת לקריאה מבחוץ ברשת, לא משנה אם יש אלמנט נראה כלשהו ב-UI שקורא לה. התייחסות אליה כאל פונקציה מקומית ומהימנה – בלי אימות והרשאה בפנים – היא הדרך הפשוטה ביותר לגרום לתחביר נוח להפוך ל-endpoint API לא מאובטח.