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

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); // يعيد الحالة إلى ما كانت عليه قبل هذه النقرة
}
};

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

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

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

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

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

المزالق والممارسات الجيدة

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

الخلاصة

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