useOptimistic הוא הוק של React 19 שמאפשר למסך להציג את התוצאה של action לפני שה-action מסתיים. קובעים ערך אופטימי בתוך action; React מציגה אותו בזמן שה-action ממתין, ואז חוזרת ל-state האמיתי. אם הבקשה נכשלה, ה-state האמיתי אף פעם לא השתנה, ולכן המסך חוזר לאחור בעצמו.
ה-API המזויף בדוגמה הזו מחליף בקשה אמיתית ונכשל בכל קריאה שלישית, כדי שתוכלו לראות את שתי התוצאות.
לחצו על הכפתור שלוש פעמים, וחכו שנייה בין לחיצה ללחיצה. שתי הראשונות מתחלפות מיד ושורת ה-saved משיגה אותן שנייה אחר כך. גם השלישית מתחלפת מיד, ואז מתהפכת בחזרה כשהבקשה נכשלת. שנו את calls % 3 ל-calls % 2 וכל לחיצה שנייה נכשלת.
התחביר
const [optimisticState, setOptimistic] = useOptimistic(state, updateFn?);
stateהוא הערך האמיתי, בדרך כלל מ-useState, מ-props או מ-useActionState. כשאף action לא ממתין,optimisticStateהוא בדיוק הערך הזה.setOptimistic(value)קובע את הערך האופטימי לכל הזמן שה-action הנוכחי רץ.updateFnאופציונלי:(currentState, optimisticValue) => nextState. איתו,setOptimisticמקבל שינוי ולא ערך חדש שלם. ראו את צורת ה-reducer בהמשך.
מחזור החיים של לחיצה אחת תמיד זהה:
- בתוך action, קוראים ל-
setOptimistic. המסך מתעדכן מיד. - ה-action מחכה לבקשה.
- בהצלחה מעדכנים את ה-state האמיתי (בתוך
startTransitionכשזה בא אחריawait). - ה-action מסתיים.
optimisticStateעוקב שוב אחריstate: הערך שנשמר בהצלחה, הערך הישן בכישלון.
שלב 4 הוא הסיבה שאין קוד rollback בדוגמה. בלוק ה-catch רק קובע הודעת שגיאה.
הוא חייב לרוץ בתוך action
הערך האופטימי קיים רק בזמן ש-transition ממתין, ולכן צריך לקרוא ל-setOptimistic בתוך אחד כזה. כל אלה נחשבים:
- הפונקציה שמעבירים ל-
startTransition(או ל-startTransitionמ-useTransition), - פונקציה שמועברת ל-
<form action={...}>או ל-<button formAction={...}>, - ה-action שנותנים ל-useActionState.
כשקוראים לו מ-onClick רגיל, לערך האופטימי אין action לחיות בו. React מחזירה אותו מיד, ובפיתוח React רושמת "An optimistic state update occurred outside a transition or action".
עדכונים ל-state האמיתי שקורים אחרי await צריכים להיעטף ב-startTransition משלהם, כמו startTransition(() => setLiked(next)) למעלה. React לא יכולה לדעת, אחרי await, שאתם עדיין בתוך ה-transition הקודם.
רשימת הודעות עם צורת ה-reducer
כשה-state האופטימי הוא רשימה, העבירו פונקציית עדכון כארגומנט השני. ה-setter מקבל אז את הפריט החדש, ו-React מוסיפה אותו לסוף הרשימה האמיתית, איך שהיא נראית באותו רגע.
שלחו שלוש הודעות עם טקסט שונה, אחת בכל פעם. כל אחת מופיעה מעומעמת עם "(sending...)" והופכת למלאה כשהיא נמסרת; השלישית נעלמת מהצ'אט ומופיעה כ-"Not sent". שלחו אותן מהר במקום, ושלושתן נשארות מעומעמות עד שהבקשה האחרונה מסתיימת, כי React שומרת ערכים אופטימיים עד שכל action ממתין הסתיים. ה-form action הוא כבר transition, ולכן addOptimistic לא צריך כאן startTransition משלו.
תנו לכל פריט אופטימי key שלא יתנגש עם פריטים אמיתיים. הדוגמה משתמשת ב-pending- ועוד הטקסט, כך ששליחת אותו טקסט פעמיים בזמן ששניהם ממתינים הייתה מתנגשת; אפליקציה אמיתית הייתה יוצרת id בצד הלקוח.
כמה עדכונים בבת אחת
מכיוון שפונקציית העדכון מקבלת את ה-state הנוכחי, עדכונים אופטימיים נערמים זה על זה. לחצו מהר וכל לחיצה ממתינה מוחלת מעל הקודמת.
לחצו ארבע פעמים מהר. העגלה מציגה 4 מיד. כשהבקשות מסתיימות, הספירה מתייצבת על 3, כי הבקשה השלישית נכשלה והקונסול אומר את זה. React שומרת את הערכים האופטימיים עד שכל action ממתין הסתיים, ואז מציגה את הספירה האמיתית.
מתי להשתמש בו, ומתי לא
עדכונים אופטימיים מתאימים ל-actions שכמעט תמיד מצליחים וקל לבטל: לייקים, כוכבים, toggles, שינוי שם, הוספת הודעה. המשתמש לא רואה ספינר במקרה הנפוץ.
הימנעו מהם במקומות שבהם "בוצע" שקרי היה מטעה: תשלומים, מחיקת חשבון, כל דבר שהמשתמש עשוי לפעול לפיו לפני שהתוצאה ידועה. בשבילם, הציגו מצב pending עם isPending מ-useActionState או מ-useTransition וחכו לתשובה האמיתית.
תמיד ספרו למשתמש כש-rollback קורה. ערך שחוזר בשקט נראה כמו באג. שתי הדוגמאות שלמעלה מחזיקות הודעת שגיאה ב-state רגיל כדי שהיא תשרוד אחרי ה-action.
עם useActionState
useOptimistic ו-useActionState משתלבים יחד. ה-action מ-useActionState הוא כבר transition, כך שאפשר לקבוע את הערך האופטימי בתחילתו, וה-state שמוחזר הוא הערך האמיתי שהאופטימי חוזר אליו.
const [state, formAction] = useActionState(async (previous, formData) => {
const title = formData.get('title');
setOptimisticTitle(title);
const saved = await saveTitle(title);
return { title: saved.title };
}, { title: 'Untitled' });
const [optimisticTitle, setOptimisticTitle] = useOptimistic(state.title);
בזמן שהשמירה רצה, הדף מציג את הכותרת החדשה. כשהיא מסתיימת, state.title מחזיק את הערך שנשמר. אם saveTitle זורק שגיאה, השגיאה של ה-action הולכת ל-error boundary הקרוב; כדי להציג במקום את הכותרת הישנה עם הודעה, תפסו את השגיאה בתוך ה-action והחזירו את ה-state הקודם ועוד שגיאה.
טעויות נפוצות
קריאה ל-setter מחוץ ל-action. הערך האופטימי לא נשאר על המסך, ו-React מזהירה בפיתוח. עטפו אותו ב-startTransition או העבירו אותו ל-form action.
לשכוח לעדכן את ה-state האמיתי. בהצלחה, ה-action חייב לשנות את ה-state ש-useOptimistic משקף (setLiked, setMessages). אחרת הערך האופטימי נעלם כשה-action מסתיים, ובקשה מוצלחת נראית כמו rollback.
עדכון ה-state האמיתי אחרי await בלי transition. העדכון עדיין נכנס, אבל React עשויה להציג אותו ברגע אחר מסוף ה-action. עטפו אותו ב-startTransition, כמו שכל שלוש הדוגמאות עושות.
שימוש בערך האופטימי כמקור האמת. שלחו בקשות וחשבו סכומים מה-state האמיתי. הערך האופטימי נועד רק לתצוגה ויכול להיזרק בכל רגע.
שאלות נפוצות
מה useOptimistic עושה?
הוא נותן לכם עותק של חלק state שאפשר לשנות מיד בזמן ש-action אסינכרוני רץ. כשה-action מסתיים, העותק חוזר לעקוב אחרי ה-state האמיתי, שעד אז מחזיק או את התוצאה שנשמרה או את הערך הישן.
איך useOptimistic עושה rollback בשגיאה?
הוא לא צריך מסלול שגיאה מיוחד. הערך האופטימי חי רק בזמן שה-action ממתין. אם הבקשה נכשלת ואף פעם לא מעדכנים את ה-state האמיתי, ההוק מציג שוב את ה-state האמיתי, שהוא הערך מלפני הלחיצה.
למה אני מקבל "An optimistic state update occurred outside a transition or action"?
ה-setter נקרא מ-event handler רגיל. קראו לו בתוך פונקציה שמועברת ל-startTransition, בתוך <form action>, או בתוך action מ-useActionState.
מה הארגומנט השני של useOptimistic?
פונקציית עדכון אופציונלית, (currentState, optimisticValue) => newState, כמו reducer. איתה ה-setter מקבל רק את השינוי (הודעה חדשה, +1) ו-React מחשבת את ה-state האופטימי, גם כשכמה עדכונים ממתינים בבת אחת.
האם useOptimistic מיועד רק לטפסים?
לא. הוא עובד בכל transition, כך שגם כפתור שקורא ל-startTransition(async () => { ... }) יכול להשתמש בו, בדיוק כמו form action.