رياكتالرسوم المتحركة

Optimistic UI في React: واجهة تكذب عن وعي

Optimistic UI في React: واجهة تكذب عن وعي

كتبتُ في مقال التفاعلات الدقيقة عن عتبة دوهرتي (Doherty threshold) – إن استجاب النظام في أقلّ من 400 مللي ثانية، يُدرِك المستخدم ذلك على أنه فوري. تسدّ التفاعلات الدقيقة هذه الفجوة بتحريك حالة الزرّ قبل عودة استجابة الخادم. أمّا Optimistic UI فتذهب خطوة أبعد: لا تكتفي بتحريك الانتظار، بل تفترض مسبقًا أنّ الإجراء سينجح، وتُظهر فورًا النتيجة النهائية – القلب في زرّ «إعجاب» يمتلئ فورًا، ويرتفع العدّاد بواحد، رغم أنّ الطلب إلى الخادم لتوّه بدأ. تخبر الواجهة المستخدم بشيء ليس صحيحًا بعد – وتفترض أنه سيصبح كذلك بعد لحظة.

يعمل هذا بشكل ممتاز ما دام الافتراض صحيحًا – وعمليًّا، بالنسبة لإجراءات مثل «إعجاب»، و«أضف إلى المفضّلة»، أو «علّم كمقروء»، يكون صحيحًا في الغالبية الساحقة من الحالات. تبدأ المشكلة حين تحاول معظم التطبيقات فعل هذا يدويًّا: setState عند النقر، وcatch مع setState معاكس عند الخطأ. يبدو الأمر بريئًا في اختبار معزول بنقرة واحدة. لكنه ينهار حين ينقر المستخدم الزرّ مرّتين سريعتين متتاليتين – العنصر الرابع نفسه من نموذج سافر (الحلقات والأنماط) من مقال التفاعلات الدقيقة، إلا أنّ الخطأ هذه المرّة ليس تجميليًّا، بل يؤدّي إلى حالة فعليًّا غير متّسقة.

لماذا ينهار التراجع اليدوي عند نقرتين

تخيّل أبسط تنفيذ يدوي وأكثره وضوحًا: النقرة تبدّل liked فورًا في الحالة المحلّية، وترسل الطلب، وفي catch تُعيد liked إلى القيمة السابقة للنقرة، المحفوظة في متغيّر قبل إرسال الطلب.

jsx

// تراجع ساذج - يعمل لنقرة واحدة، وينهار عند نقرتين
const handleClick = async () => {
const previousLiked = liked; // لقطة مجمَّدة للحالة قبل هذه النقرة
setLiked(!liked);
try {
await toggleLike(postId, !liked);
} catch {
setLiked(previousLiked); // يُعيد إلى الحالة السابقة لهذه النقرة تحديدًا
}
};

السيناريو الذي يُفسِد هذا: ينقر المستخدم «إعجاب» (يبدأ الطلب أ، وَpreviousLiked = false)، وبعد 100 مللي ثانية ينقر «إلغاء الإعجاب» (يبدأ الطلب ب، وَpreviousLiked = true، لأنّ الحالة المحلّية تغيّرت بالفعل إلى true). إن فشل الطلب أ، بينما لا يزال الطلب ب جاريًا أو نجح بالفعل، فإنّ catch الخاص بالطلب أ سيُعيد الحالة إلى false – فيمحو أثر النقرة ب، التي نفّذها المستخدم عن وعي وربما نجحت بالفعل. تراجع طلب واحد ألغى نتيجة طلب آخر، لأنّ كليهما يعمل على المتغيّر المشترك نفسه للحالة، دون أيّ معرفة بالآخر. هذا بالضبط نوع الخطأ الذي لا يظهر في اختبار يدوي سريع – بل يظهر في الإنتاج، حين ينقر مستخدم حقيقي أسرع ممّا تخيّله المطوّر أثناء كتابة الكود.

كيف يتجنّب useOptimistic هذه المشكلة بنيويًّا، لا برقعة

useOptimistic في React ليست غلافًا أكثر راحة حول setState وَtry/catch. إنها نموذج مفاهيمي مختلف: بدلًا من الاحتفاظ بحقل حالة واحد قابل للتغيير تُبدِّله وتُعيده يدويًّا، تُحسِب useOptimistic طبقة مؤقتة على أساس الحالة الحقيقية الراهنة، مرئية فقط طوال مدّة عملية معيّنة (transition). حين تنتهي العملية – سواء بنجاح أو فشل – تختفي الطبقة تلقائيًّا، كاشفةً عمّا هو الحالة الحقيقية في تلك اللحظة. لا تكتب كودًا للتراجع. التراجع نتيجة طبيعية لاختفاء الطبقة المؤقتة، لا مسار كود منفصل قد تنساه أو تنفّذه بشكل خاطئ.

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);
// دون تراجع يدوي في 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 المُمرَّر كوسيط أوّل – أي على الحالة الحقيقية الراهنة الآتية من الخصائص (عادةً من Server Action وَrevalidatePath، كما في مقال Server Actions) – لا على لقطة منفصلة ومجمَّدة محفوظة يدويًّا في متغيّر محلّي. حين ينقر المستخدم مرّتين متتاليتين، يحسب كلّ استدعاء لـsetOptimistic طبقة جديدة بالنسبة إلى الحالة الراهنة، فلا يمكن للنقرة الثانية أن تُمحى عن طريق الخطأ بتراجع متأخّر من النقرة الأولى – لا وجود لمسار منفصل «عُد إلى القيمة المحفوظة» قد يُخطئ في تحديد اللحظة الزمنية المقصودة بالضبط.

متى يكون Optimistic UI فكرة جيدة، ومتى يكون كذبة خطيرة

لا يناسب كلّ إجراء هذا النمط، وهذه ليست مسألة ذوق، بل نتيجة لخطأ الافتراض. «إعجاب»، و«أضف إلى المفضّلة»، و«علّم كمقروء» – رخيصة العواقب، وسهلة العكس، وتنجح في الغالب دائمًا. أمّا الدفع، وحذف حساب لا رجعة فيه، وإرسال رسالة إلى شخص آخر – فهنا يصبح إظهار النجاح تفاؤليًّا قبل أن يتحقّق فعليًّا أمرًا محفوفًا بالمخاطر: يتّخذ المستخدم قرارات لاحقة بناءً على معلومة قد تتبيّن أنها خاطئة، والتراجع عن هذه «الكذبة» بعد ثوانٍ قليلة أكثر إرباكًا بكثير من مؤشّر تحميل بسيط يستغرق فترة أطول قليلًا.

حتى حيث يكون لـOptimistic UI معنى، فإنّ التراجع الصامت دون أيّ إشعار يُعيد إنتاج المشكلة الإدراكية نفسها التي كان مفترضًا أن يحلّها. رأى المستخدم القلب ممتلئًا، واعتبر الأمر منتهيًا، وحوّل انتباهه إلى مكان آخر – ثم يعود القلب بصمت إلى حالته الفارغة بعد لحظة، دون تفسير. هذا الخطأ نفسه – «غياب الحلقات والأنماط» من نموذج سافر – لكن منقولًا في الزمن: كانت هناك تغذية راجعة عن النجاح، لكن لا توجد تغذية راجعة عن الفشل. الحلّ هو الأداة نفسها التي وصفتُها في مقال إتاحة الوصول – aria-live="polite" أو role="alert" مع رسالة الفشل، حتى لا يمرّ تراجع الحالة صامتًا بصريًّا ولا صوتيًّا لمن لا ينظر تحديدًا إلى ذلك الزرّ في لحظة تراجعه.

الفخاخ والممارسات الجيدة

  • يجب استدعاء setOptimistic داخل transition. خارج startTransition (أو إجراء مُمرَّر إلى useActionState/<form action>)، لن تعمل useOptimistic كما هو متوقّع – الآلية مرتبطة ارتباطًا وثيقًا بدورة حياة عملية transition محدَّدة، لا بأيّ لحظة عرض عشوائية.
  • يجب أن تكون دالة التحديث الممرَّرة إلى useOptimistic نقيّة ورخيصة. تُنفَّذ بشكل متزامن أثناء العرض لحساب الطبقة – فأيّ حسابات معقّدة أو تأثيرات جانبية في هذا الموضع خطأ من فئة مختلفة، لا مجرّد مسألة أداء.
  • لا تُخفِ الفشل بصمت. التراجع الصامت دون رسالة أسوأ من عدم استخدام تحديث تفاؤلي على الإطلاق – يحصل المستخدم على تأكيد كاذب، ثم لا يتلقّى بعدها أيّ معلومة بأنّ شيئًا ما قد خرج عن السيطرة.
  • احتفظ بهذا النمط للإجراءات الرخيصة والقابلة للعكس. حيث تكون كلفة الافتراض الخاطئ مرتفعة (المدفوعات، والعمليات غير القابلة للعكس)، فإنّ الخيار الأفضل هو حالة تحميل عادية مع تأكيد واضح بعد وقوع الفعل، لا واجهة تُخمِّن مسبقًا.

الخلاصة

Optimistic UI ليست حيلة تحريك – إنها افتراض واعٍ بأنّ الإجراء سينجح، يُعرَض على المستخدم قبل أن يؤكّده الخادم. التنفيذ اليدوي عبر setState وَcatch يعمل ما دام لم ينقر أحد أسرع ممّا توقّعه المطوّر – وعندها يمكن لتراجع عملية واحدة أن يمحو أثر عملية أخرى، لأنّ كلتيهما تتشارك اللقطة المجمَّدة نفسها للحالة. تزيل useOptimistic هذه الفئة من الأخطاء بنيويًّا: تُحسَب الطبقة دائمًا بالنسبة إلى الحالة الحقيقية الراهنة، لا بالنسبة إلى قيمة محفوظة بشكل منفصل، فلا يكون التراجع كودًا تكتبه ويمكن أن تكتبه بشكل خاطئ – بل نتيجة طبيعية لاختفاء الافتراض المؤقّت. يبقى قرار واحد لا يستطيع أيّ واجهة برمجية أن تتّخذه نيابة عنك: هل الإجراء رخيص وقابل للعكس بما يكفي ليكون منطقيًّا الكذب بشأنه أصلًا، ولو للحظة.