useOptimistic: نمایش فوری نتیجه‌ای که هنوز قطعی نشده

در دو درس قبل دیدیم یک Action تا پایان خودش isPending دارد. اما در بسیاری از رابط‌های کاربری، منتظر ماندن برای پاسخ سرور حس کندی ایجاد می‌کند. مثلاً کاربر در یک چت پیام می‌فرستد و تا رسیدن پاسخ سرور، پیامش روی صفحه دیده نمی‌شود. useOptimistic به ما اجازه می‌دهد نتیجه‌ای را که انتظار داریم رخ بدهد، همان لحظه نشان بدهیم و بعد با نتیجه واقعی هماهنگش کنیم.

ایده: خوش‌بینانه فرض کردن موفقیت

Optimistic UI یعنی رابط کاربری را طوری به‌روز کنیم که انگار عملیات از قبل موفق شده است، و منتظر تأیید سرور نمانیم. اگر عملیات واقعاً موفق شد، چیزی عوض نمی‌شود؛ اگر شکست خورد، رابط کاربری به حالت قبل برمی‌گردد. این الگو برای عملیاتی مناسب است که معمولاً موفق می‌شوند، مثل ارسال پیام یا لایک کردن.

نحو پایه

jsx
import { useOptimistic } from "react";

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
  • state: مقدار واقعی و قطعی، که معمولاً از Props یا State والد می‌آید.
  • updateFn: تابعی اختیاری که مقدار واقعی فعلی و مقدار جدید را می‌گیرد و حالت خوش‌بینانه را برمی‌گرداند.
  • optimisticState: مقداری که باید در JSX نمایش بدهید. معمولاً برابر state است، و فقط در طول اجرای یک Action، شامل تغییر خوش‌بینانه می‌شود.
  • addOptimistic: تابعی که تغییر خوش‌بینانه را اعلام می‌کند.

مثال: چت با نمایش فوری پیام

jsx
function Thread({ messages, sendMessage }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (current, newText) => [...current, { text: newText, sending: true }]
  );

  async function formAction(formData) {
    const text = formData.get("message");
    addOptimisticMessage(text);
    await sendMessage(text);
  }

  return (
    <>
      {optimisticMessages.map((m, i) => (
        <p key={i}>{m.text} {m.sending && "(در حال ارسال...)"}</p>
      ))}
      <form action={formAction}>
        <input name="message" />
        <button type="submit">ارسال</button>
      </form>
    </>
  );
}

به‌محض ارسال فرم، addOptimisticMessage پیام را با برچسب «در حال ارسال» به لیست اضافه می‌کند و کاربر فوراً آن را می‌بیند. تا زمانی که await sendMessage(text) تمام نشود، لیست همین نسخه خوش‌بینانه است.

چه زمانی به حالت واقعی برمی‌گردد

این مهم‌ترین نکته درس است. وقتی Action تمام می‌شود، چه موفق و چه با خطا، optimisticState دوباره برابر مقدار واقعی state می‌شود. یعنی خود Hook هیچ‌چیز را ذخیره نمی‌کند. پس اگر در Action موفق، مقدار واقعی را عوض نکنید، پیام خوش‌بینانه ناپدید می‌شود. در مثال بالا، تابع sendMessage که از والد می‌آید باید بعد از موفقیت، لیست واقعی messages را به‌روز کند؛ نتیجه این است که پیام «در حال ارسال» جایش را به همان پیام قطعی می‌دهد.

خطا: بازگشت خودکار، ولی اطلاع‌رسانی با خودتان

اگر Action شکست بخورد، پیام خوش‌بینانه خودکار از لیست برمی‌گردد و کاربر لیست قبلی را می‌بیند. اما React پیامی مثل «ارسال نشد» نشان نمی‌دهد. اگر خطا را به‌صورت State برگردانید (مثل useActionState در درس قبل)، می‌توانید دلیل را نشان بدهید. بدون این کار، پیام فقط بی‌صدا ناپدید می‌شود و کاربر نمی‌داند چه شد.

قانون: addOptimistic باید داخل یک Action صدا زده شود

addOptimistic را فقط باید داخل یک Action صدا زد: تابع داده‌شده به ویژگی action یک فرم (مثل مثال بالا) یا تابع داخل startTransition. دلیلش این است که حالت خوش‌بینانه فقط تا پایان همان Transition زنده است. اگر آن را بیرون از Action صدا بزنید، React هشدار می‌دهد و مقدار خوش‌بینانه ماندگاری نخواهد داشت.

کجا استفاده نکنیم

این الگو، نتیجه‌ای را نشان می‌دهد که هنوز قطعی نیست. برای کارهایی مثل لایک یا افزودن یک پیام، اگر شکست بخورد، برگشتن رابط کاربری قابل‌تحمل است. اما برای عملیاتی که نتیجه نادرستشان پرهزینه است (مثل پرداخت یا حذف دائمی)، نشان دادن «انجام شد» پیش از تأیید واقعی گمراه‌کننده است و بهتر است با isPending معمولی منتظر نتیجه ماند.

مثال قابل اجرا

در این مثال، ارسال پیام یک ثانیه طول می‌کشد. پیام همان لحظه با برچسب «در حال ارسال» ظاهر می‌شود و بعد از یک ثانیه به پیام قطعی تبدیل می‌شود. اگر متن «خطا» را بفرستید، ارسال شکست می‌خورد و پیام خوش‌بینانه از لیست حذف می‌شود.

چت با useOptimistic
import { useState, useOptimistic } from "react";

function fakeDeliver(text) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (text === "خطا") reject(new Error("ارسال نشد"));
      else resolve(text);
    }, 1000);
  });
}

function Thread({ messages, sendMessage }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (current, newText) => [...current, { text: newText, sending: true }]
  );

  async function formAction(formData) {
    const text = formData.get("message");
    addOptimisticMessage(text);
    await sendMessage(text);
  }

  return (
    <div>
      {optimisticMessages.map((m, i) => (
        <p key={i} style={{ opacity: m.sending ? 0.5 : 1 }}>
          {m.text} {m.sending && "(در حال ارسال...)"}
        </p>
      ))}
      <form action={formAction}>
        <input name="message" placeholder="پیام" />
        <button type="submit">ارسال</button>
      </form>
    </div>
  );
}

function App() {
  const [messages, setMessages] = useState([{ text: "سلام!" }]);

  async function sendMessage(text) {
    try {
      const sent = await fakeDeliver(text);
      setMessages((current) => [...current, { text: sent }]);
    } catch (e) {
      console.log("ارسال شکست خورد");
    }
  }

  return <Thread messages={messages} sendMessage={sendMessage} />;
}
برای دیدن پیش‌نمایش کامپوننت، روی Run بزنید.

جمع‌بندی

useOptimistic اجازه می‌دهد در طول اجرای یک Action، نتیجه مورد انتظار را فوراً نشان دهیم. این Hook چیزی ذخیره نمی‌کند: با پایان Action، چه موفق و چه ناموفق، مقدار نمایش‌داده‌شده به مقدار واقعی برمی‌گردد، پس موفقیت باید مقدار واقعی را عوض کند. addOptimistic فقط داخل Action مجاز است، و خطا بی‌صدا حالت قبل را برمی‌گرداند مگر خودتان آن را اعلام کنید. این الگو برای عملیات کم‌ریسک مناسب است، نه برای کارهایی که نتیجه نادرستشان پرهزینه است. در درس بعدی، به Hook جدید use می‌پردازیم.