useSyncExternalStore: خواندن داده‌ای که React مالک آن نیست

در درس قبل با useId آشنا شدیم. دومین API مدرن این فصل، useSyncExternalStore است. این Hook برای موقعیتی ساخته شده که کامپوننت باید مقداری را بخواند که بیرون از React نگه‌داری می‌شود و خودش تغییر می‌کند؛ مثل وضعیت اتصال اینترنت مرورگر، اندازه پنجره، یا یک Store ساخته‌شده با کتابخانه‌ای که به React وابسته نیست.

مشکل: داده‌ای که React از تغییرش خبر ندارد

تا اینجا، هر مقداری که در رابط کاربری نشان دادیم یا در State خود React بود یا از Props می‌آمد. اما یک Store بیرونی، مثلاً یک آبجکت جاوااسکریپتی در یک ماژول جدا، با تغییر مقدارش به React اطلاع نمی‌دهد. اگر فقط مقدار را در بدنه کامپوننت بخوانیم، کامپوننت تا رندر بعدی از تغییر بی‌خبر می‌ماند.

راه قدیمی: useState و useEffect

در فصل دهم، useOnlineStatus را با همین الگو ساختیم: یک State برای نگه‌داشتن مقدار، و یک Effect برای اشتراک در رویداد و به‌روزرسانی State. این الگو در بسیاری از موارد کار می‌کند، اما دو ضعف دارد. اول، مقدار اولیه در رندر اول خوانده می‌شود و اگر بین رندر و اجرای Effect مقدار عوض شود، یک رندر اضافه رخ می‌دهد. دوم، و مهم‌تر، در Concurrent Rendering که در فصل بیست‌ودوم دیدیم، رندر می‌تواند وسط کار متوقف شود. اگر مقدار Store بیرونی همان لحظه تغییر کند، بخشی از درخت ممکن است با مقدار قدیمی و بخشی با مقدار جدید رندر شود. به این ناهماهنگی Tearing گفته می‌شود: صفحه‌ای که قسمت‌هایش از دو نسخه متفاوت یک داده ساخته شده‌اند.

راه‌حل: useSyncExternalStore

jsx
import { useSyncExternalStore } from "react";

const snapshot = useSyncExternalStore(subscribe, getSnapshot);

این Hook دو آرگومان ضروری دارد:

  • subscribe: تابعی که یک Callback می‌گیرد، آن را به Store بیرونی وصل می‌کند و یک تابع لغو اشتراک برمی‌گرداند؛ ساختاری شبیه Effect و Cleanup که در فصل ششم دیدیم.
  • getSnapshot: تابعی که مقدار فعلی Store را برمی‌گرداند.

React در هر رندر، مقدار را از getSnapshot می‌خواند و با هر اعلام تغییر از طرف subscribe، دوباره آن را بررسی می‌کند. چون خواندن مقدار در حین رندر و به‌شکل هماهنگ با Scheduler انجام می‌شود، Tearing رخ نمی‌دهد.

نمونه: وضعیت آنلاین بودن

jsx
function subscribe(callback) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot);
}

این نسخه از useOnlineStatus، همان کار فصل دهم را با کدی کوتاه‌تر انجام می‌دهد و نیاز به useState و useEffect ندارد.

دو قانون مهم

اول، تابع subscribe باید بین رندرها پایدار باشد. اگر آن را داخل کامپوننت تعریف کنید، با هر رندر یک تابع تازه ساخته می‌شود و React هر بار اشتراک را لغو و دوباره برقرار می‌کند. معمولاً بهترین کار، تعریف آن بیرون از کامپوننت است.

دوم، getSnapshot باید برای یک Store بدون تغییر، هر بار دقیقاً همان مقدار (با همان مرجع) را برگرداند. این همان مفهوم Referential Equality فصل سیزدهم است. اگر هر بار یک Object تازه برگردانید، React آن را تغییرکرده تشخیص می‌دهد و رندر پشت رندر ایجاد می‌شود:

jsx
// نادرست: هر فراخوانی یک Object جدید می‌سازد
function getSnapshot() {
  return { count: store.count };
}

// درست: مقدار ساده یا همان مرجع ذخیره‌شده
function getSnapshot() {
  return store.count;
}

آرگومان سوم: getServerSnapshot

Hook یک آرگومان سوم اختیاری هم دارد که مقدار اولیه را هنگام رندر روی سرور و هنگام هماهنگ‌سازی اولیه در مرورگر می‌دهد. چون سرور به window و navigator دسترسی ندارد، این آرگومان در برنامه‌های دارای رندر سمت سرور لازم می‌شود. جزئیات آن در فصل بیست‌وچهارم، هنگام بحث Server Components و معماری Server/Client، می‌آید.

چه زمانی از آن استفاده کنیم

این Hook ابزار مقابله با داده‌های بیرون از React است، نه جایگزین useState. اگر مقدار را خودتان می‌سازید و مالک آن هستید، State معمولی درست‌تر است. اگر داده از سرور می‌آید، همان‌طور که در فصل چهاردهم دیدیم، با ابزارهای Server State مدیریت می‌شود. در عمل، بیشتر برنامه‌نویس‌ها مستقیم با این Hook کار نمی‌کنند؛ کتابخانه‌های مدیریت State که در فصل شانزدهم دیدیم، پشت صحنه از آن استفاده می‌کنند تا با Concurrent Rendering سازگار باشند. با این حال، آشنایی با آن کمک می‌کند بفهمیم چرا آن کتابخانه‌ها این‌طور ساخته شده‌اند، و وقتی با یک API مرورگر رودررو شدیم، ابزار درست را بشناسیم.

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

در این مثال، یک Store ساده بیرون از React ساخته شده و دو کامپوننت از آن می‌خوانند. دکمه‌ها مقدار Store را مستقیم و بدون هیچ set از React تغییر می‌دهند، اما هر دو کامپوننت هماهنگ به‌روز می‌شوند.

یک Store بیرونی ساده با useSyncExternalStore
import { useSyncExternalStore } from "react";

const store = {
  count: 0,
  listeners: new Set(),
  increment() {
    store.count += 1;
    store.listeners.forEach((listener) => listener());
  },
  subscribe(listener) {
    store.listeners.add(listener);
    return () => store.listeners.delete(listener);
  },
  getSnapshot() {
    return store.count;
  },
};

function CountLabel({ name }) {
  const count = useSyncExternalStore(store.subscribe, store.getSnapshot);
  return <p>{name}: {count}</p>;
}

function App() {
  return (
    <div>
      <CountLabel name="نمایشگر اول" />
      <CountLabel name="نمایشگر دوم" />
      <button onClick={store.increment}>افزایش در Store بیرونی</button>
    </div>
  );
}
برای دیدن پیش‌نمایش کامپوننت، روی Run بزنید.

جمع‌بندی

useSyncExternalStore برای خواندن داده‌ای است که بیرون از React نگه‌داری می‌شود و خودش تغییر می‌کند. با گرفتن یک تابع subscribe و یک تابع getSnapshot، مقدار را هماهنگ با رندر می‌خواند و از Tearing در Concurrent Rendering جلوگیری می‌کند. subscribe باید پایدار و getSnapshot باید برای داده بدون تغییر، همان مقدار قبلی را برگرداند. این Hook جایگزین useState نیست و بیشتر در کتابخانه‌ها یا هنگام کار با APIهای مرورگر به کار می‌آید. در درس بعدی، به useInsertionEffect می‌پردازیم.