ריאקטאנימציות

Optimistic UI ב-React: ממשק ששקר בכוונה

Optimistic UI ב-React: ממשק ששקר בכוונה

במאמר על מיקרו-אינטראקציות כתבתי על סף דוהרטי – אם מערכת עונה תוך פחות מ-400 מילישניות, המשתמש תופס אותה כמיידית. מיקרו-אינטראקציות סוגרות את הפער הזה באמצעות אנימציה של מצב הכפתור, עוד לפני שתגובת השרת חוזרת. Optimistic UI הולך צעד רחוק יותר: הוא לא רק מאנימץ את ההמתנה, אלא מניח מראש שהפעולה תצליח, ומציג מיד את התוצאה הסופית – הלב בכפתור "לייק" מתמלא באופן מיידי, המונה עולה באחד, למרות שהבקשה לשרת רק יצאה לדרך. הממשק אומר למשתמש משהו שעדיין לא נכון – ומניח שבעוד רגע זה יהיה נכון.

זה עובד מצוין כל עוד ההנחה נכונה – ובפועל, עבור פעולות מסוג "לייק", "הוסף למועדפים" או "סמן כנקרא", ההנחה נכונה ברוב המכריע של המקרים. הבעיה מתחילה במקום שרוב היישומים מנסים לעשות את זה ידנית: setState בלחיצה, catch עם setState הפוך במקרה של שגיאה. זה נראה תמים לגמרי בבדיקה מבודדת עם לחיצה אחת. זה מתפרק כשמשתמש לוחץ על הכפתור פעמיים ברצף מהיר – בדיוק אותו רכיב רביעי במודל של Saffer (לולאות ומצבים) מהמאמר על מיקרו-אינטראקציות, רק שהפעם השגיאה היא לא קוסמטית, אלא מובילה למצב לא עקבי באמת.

למה rollback ידני מתפרק בשתי לחיצות

תארו לעצמכם את המימוש הידני הכי מובן מאליו: לחיצה מחליפה מיד את liked במצב המקומי, שולחת בקשה, וב-catch מחזירה את liked לערך שהיה לפני הלחיצה, שנשמר במשתנה לפני שליחת הבקשה.

jsx

// Rollback נאיבי – עובד ללחיצה אחת, נשבר בשתיים
const handleClick = async () => {
const previousLiked = liked; // תמונת מצב קפואה מלפני הלחיצה
setLiked(!liked);
try {
await toggleLike(postId, !liked);
} catch {
setLiked(previousLiked); // חוזר למצב שהיה לפני הלחיצה הזו
}
};

התרחיש ששובר את זה: המשתמש לוחץ "לייק" (בקשה A מתחילה, previousLiked = false), אחרי 100 מילישניות לוחץ "הסר לייק" (בקשה B מתחילה, previousLiked = true, כי המצב המקומי כבר הספיק להשתנות ל-true). אם בקשה A נכשלת, ובקשה B בדיוק באמצע או כבר הצליחה, ה-catch של בקשה A יחזיר את המצב ל-false – וידרוס את התוצאה של לחיצה B, שהמשתמש ביצע במודע ושייתכן שכבר הצליחה. ה-rollback של בקשה אחת מחק את התוצאה של השנייה, כי שתיהן פעלו על אותו משתנה מצב משותף, בלי שום ידיעה זו על זו. זה בדיוק סוג השגיאה שלא מתגלה בבדיקה ידנית מהירה – היא מתגלה בפרודקשן, כשמשתמש אמיתי לוחץ מהר יותר ממה שהמפתח דמיין בזמן כתיבת הקוד.

איך useOptimistic נמנע מהבעיה הזו באופן מבני, לא בטלאי

useOptimistic ב-React הוא לא עטיפה נוחה יותר סביב setState ו-try/catch. זהו מודל מושגי שונה לגמרי: במקום להחזיק שדה מצב יחיד וניתן לשינוי, שמחליפים ידנית ומחזירים ידנית, useOptimistic מחשב שכבת-על זמנית באופן שוטף, על בסיס המצב האמיתי הנוכחי, שנראית רק למשך הפעולה (transition) הספציפית. כשהפעולה מסתיימת – בהצלחה או בכישלון – שכבת-העל נעלמת מעצמה, וחושפת את מה שבאמת המצב הנוכחי באותו רגע. לא כותבים קוד rollback. ה-rollback הוא תוצאה טבעית של היעלמות שכבת-העל הזמנית, לא נתיב קוד נפרד שאפשר לשכוח לטפל בו או לטפל בו בצורה שגויה.

jsx

"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLikeAction } from "./actions";
const LikeButton = ({ postId, liked, likeCount }) => {
const [isPending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic(
{ liked, likeCount },
(state, nextLiked) => ({
liked: nextLiked,
likeCount: state.likeCount + (nextLiked ? 1 : -1),
}),
);
const handleClick = () => {
const nextLiked = !optimistic.liked;
startTransition(async () => {
setOptimistic(nextLiked);
await toggleLikeAction(postId, nextLiked);
// בלי rollback ידני ב-catch – אם הפעולה זורקת שגיאה,
// React בכל מקרה יזרוק את שכבת-העל בסיום ה-transition,
// ויחשוף את ה-"liked" האמיתי שהתקבל ב-props.
});
};
return (
<button
type="button"
onClick={handleClick}
aria-pressed={optimistic.liked}
className={isPending ? "like-button--pending" : ""}
>
<span aria-hidden="true">{optimistic.liked ? "♥" : "♡"}</span>
{optimistic.likeCount}
</button>
);
};
export default LikeButton;

ההבדל המכריע לעומת הגרסה הידנית: שכבת-העל שמחושבת על ידי useOptimistic מבוססת על ה-state שמועבר כארגומנט ראשון – כלומר על המצב האמיתי והנוכחי מה-props (בדרך כלל מגיע מ-Server Action ומ-revalidatePath, כמו במאמר על Server Actions) – ולא על תמונת מצב קפואה ונפרדת שנשמרת ידנית במשתנה מקומי. כשהמשתמש לוחץ פעמיים ברצף, כל קריאה ל-setOptimistic מחשבת שכבת-על חדשה ביחס למצב הנוכחי, כך שהלחיצה השנייה לא יכולה להידרס בטעות על ידי rollback מאוחר של הראשונה – אין נתיב נפרד של "חזור לערך ששמרתי" שיכול להתבלבל לגבי איזה בדיוק רגע בזמן הכוונה אליו.

מתי optimistic UI הוא רעיון טוב, ומתי הוא שקר מסוכן

לא כל פעולה מתאימה לתבנית הזו, וזו לא שאלה של טעם אלא של תוצאות במקרה שההנחה מתבררת כשגויה. "לייק", "הוסף למועדפים", "סמן כנקרא" – זולות בהשלכות, קלות להיפוך, כמעט תמיד מצליחות. תשלום, מחיקת חשבון בלתי הפיכה, שליחת הודעה לאדם אחר – כאן הצגה אופטימית של הצלחה, לפני שהיא בפועל התרחשה, היא מסוכנת: המשתמש מקבל החלטות נוספות על בסיס מידע שעלול להתברר כשקרי, וביטול "השקר" הזה כמה שניות אחר כך מבלבל הרבה יותר מ-spinner רגיל שפשוט נמשך קצת יותר זמן.

גם במקום שבו optimistic UI כן הגיוני, rollback שקט לגמרי, בלי שום הודעה, משחזר בדיוק את אותה בעיה תפיסתית שהוא היה אמור לפתור. המשתמש ראה את הלב מתמלא, החשיב את העניין כסגור, העביר את תשומת הלב למקום אחר – ורגע אחר כך הלב חוזר בשקט למצב ריק, בלי הסבר. זו אותה שגיאת "היעדר לולאות ומצבים" ממודל Saffer, רק שהזזתי אותה בזמן: משוב על הצלחה היה, אבל משוב על כישלון לא היה. הפתרון הוא אותו כלי שתיארתי במאמר על נגישות – aria-live="polite" או role="alert" בהודעה על כישלון, כדי שהחזרה של המצב לא תהיה שקטה מבחינה חזותית או קולית עבור מי שבאותו רגע לא בהכרח מסתכל ישירות על הכפתור הזה.

מלכודות ושיטות עבודה טובות

  • setOptimistic חייב להיקרא בתוך transition. מחוץ ל-startTransition (או action שמועברת ל-useActionState/<form action>) useOptimistic לא יעבוד כמצופה – המנגנון קשור באופן בלתי נפרד למחזור החיים של transition ספציפית, לא לרגע רנדור כלשהו.
  • פונקציית העדכון שמועברת ל-useOptimistic חייבת להיות טהורה וזולה. היא רצה באופן סינכרוני במהלך הרנדור כדי לחשב את שכבת-העל – חישובים מורכבים או תופעות לוואי במקום הזה הם שגיאה מסוג שונה לגמרי, לא רק עניין של ביצועים.
  • אל תסתירו כישלון בשקט. rollback שקט בלי הודעה גרוע יותר מאשר היעדר עדכון אופטימי מלכתחילה – המשתמש מקבל אישור שקרי, ואז שום מידע שבכל זאת משהו השתבש.
  • שמרו את התבנית לפעולות זולות והפיכות. במקום שבו העלות של הנחה שגויה גבוהה (תשלומים, פעולות בלתי הפיכות), הבחירה הטובה יותר היא מצב טעינה רגיל עם אישור ברור אחרי המעשה, לא ממשק שמנחש מראש.

סיכום

Optimistic UI הוא לא טריק אנימציה – זו הנחה מודעת שהפעולה תצליח, מוצגת למשתמש עוד לפני שהשרת מאשר זאת. מימוש ידני באמצעות setState ו-catch עובד כל עוד אף אחד לא לוחץ מהר יותר ממה שהמפתח צפה – ואז ה-rollback של פעולה אחת עלול לדרוס את התוצאה של השנייה, כי שתיהן חולקות את אותה תמונת מצב קפואה. useOptimistic מסיר את מחלקת השגיאות הזו באופן מבני: שכבת-העל תמיד מחושבת ביחס למצב האמיתי הנוכחי, לא ביחס לערך שנשמר בנפרד, כך שה-rollback הוא לא קוד שאתם כותבים ועלולים לכתוב לא נכון – הוא תוצאה טבעית של היעלמות ההנחה הזמנית. נשארת החלטה אחת שאף API לא יקבל במקומכם: האם הפעולה הנתונה זולה ומספיק הפיכה כדי שבכלל יהיה טעם לשקר לגביה, ולו לרגע.