در نوشتهی قبلی از useSyncExternalStore استفاده کردم تا هر ویجت فقط وقتی دادهی خودش عوض میشود رندر شود. آنجا هوک را مثل یک جعبهی سیاه به کار بردم: subscribe و getSnapshot را میدهی و مقدار را پس میگیری. این بار میخواهم جعبه را باز کنم و هوکی با همین رفتار را با useState و useEffect بسازم.
قصد ندارم این هوک را جای نسخهی اصلی در پروژه بگذارم. ارزشش در این است که هر خط نسخهی نهایی جواب یک باگ مشخص است، و وقتی آن باگها را یکییکی ببینید، قاعدههایی مثل «getSnapshot باید مقدار cacheشده برگرداند» دیگر قانونی دلبخواهی به نظر نمیرسند.
اول قرارداد را مشخص کنیم
امضای هوک همان امضای نسخهی اصلی است:
function useStore<T>(
subscribe: (onChange: () => void) => () => void,
getSnapshot: () => T,
): T;
و اینها چیزهایی است که از آن انتظار داریم:
۱. در هر رندر، مقدار فعلی store را برگرداند، نه مقداری که یکبار جایی ذخیره شده.
۲. وقتی store تغییر کرد و snapshot این کامپوننت هم عوض شد، کامپوننت دوباره رندر شود.
۳. اگر store تغییر کرد ولی snapshot با Object.is همان مقدار قبلی بود، رندری اتفاق نیفتد. کل ترفند نوشتهی قبلی به همین شرط تکیه داشت.
۴. هیچ تغییری گم نشود، حتی تغییری که بین رندر و subscribe شدن رخ بدهد.
۵. اگر subscribe یا getSnapshot عوض شد (مثلاً prop مربوط به id تغییر کرد)، هوک با تابع جدید کار کند.
برای آزمایش هم یک store خیلی ساده لازم داریم:
function createStore<T>(initial: T) {
let state = initial;
const listeners = new Set<() => void>();
return {
get: () => state,
set(next: T) {
state = next;
listeners.forEach((l) => l());
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
const counter = createStore(0);
نسخهی اول: کپی کردن مقدار در state
اولین چیزی که به ذهن میرسد این است که مقدار store را در یک state نگه داریم و در useEffect، هر وقت store خبر داد، آن state را آپدیت کنیم:
function useStore<T>(subscribe, getSnapshot: () => T): T {
const [value, setValue] = useState(getSnapshot);
useEffect(() => {
return subscribe(() => setValue(getSnapshot()));
}, [subscribe, getSnapshot]);
return value;
}
با یک شمارندهی ساده درست کار میکند و شرط سوم را هم مجانی دارد، چون ریاکت وقتی مقدار جدید state با Object.is برابر مقدار قبلی باشد، از رندر صرفنظر میکند. ولی دو باگ جدی دارد.
باگ اول: تغییری که بین رندر و effect اتفاق میافتد گم میشود. useEffect بعد از commit و معمولاً بعد از paint اجرا میشود. اگر در همین فاصله store تغییر کند، هنوز listenerی ثبت نشده که خبردار شود. سناریوی واقعیاش هم عجیب نیست: effectهای کامپوننتهای همسطح به ترتیب اجرا میشوند، و اگر کامپوننتی که قبل از ما در درخت آمده در effect خودش مقداری را از localStorage بخواند و در store بنویسد، effect ما بعد از آن subscribe میکند:
function RestoreFromStorage() {
useEffect(() => {
counter.set(Number(localStorage.getItem("count") ?? 0));
}, []);
return null;
}
function Counter() {
const count = useStore(counter.subscribe, counter.get);
return <span>{count}</span>;
}
// <RestoreFromStorage /> <Counter />
Counter با مقدار ۰ رندر میشود، RestoreFromStorage مقدار را مثلاً ۷ میکند و فقط بعد از آن Counter subscribe میکند. صفحه تا تغییر بعدیِ store عدد ۰ را نشان میدهد.
باگ دوم: عوض شدن getSnapshot دیده نمیشود. مقدار اولیهی useState فقط در mount خوانده میشود. فرض کنید ویجت نوشتهی قبلی id را از prop بگیرد و آن prop از "views" به "sales" تغییر کند. effect دوباره subscribe میکند، ولی value هنوز عدد مربوط به views است و تا وقتی store دوباره خبر ندهد همان را نشان میدهد. این همان قاعدهی اول است که زیر پا گذاشتیم: مقدار را کپی کردیم و از آن به بعد کپی و اصل از هم جدا افتادند.
نسخهی دوم: در رندر بخوان، state را فقط برای رندر مجدد نگه دار
راهحل باگ دوم این است که مقدار را در state کپی نکنیم و در هر رندر مستقیم از getSnapshot() بخوانیم. state فقط برای این میماند که بتوانیم رندر مجدد را به زور راه بیندازیم:
function useStore<T>(subscribe, getSnapshot: () => T): T {
const value = getSnapshot();
const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
return subscribe(forceUpdate);
}, [subscribe]);
return value;
}
باگ دوم رفت، چون هر رندر getSnapshot فعلی را صدا میزند. ولی شرط سوم را از دست دادیم: حالا هر تغییری در store همهی subscriberها را رندر میکند، حتی آنهایی که snapshotشان تغییری نکرده. در لیست ویجتها یعنی دقیقاً همان رفتار Context که میخواستیم از آن فرار کنیم. باگ اول هم هنوز سر جایش است.
نسخهی سوم: مقایسه قبل از رندر
برای برگرداندن شرط سوم باید قبل از forceUpdate بپرسیم: snapshot واقعاً عوض شده؟ برای این مقایسه، listener باید آخرین مقدار رندرشده و آخرین getSnapshot را داشته باشد. listener یکبار موقع subscribe ساخته میشود، پس اگر این دو را از closure بخواند، مقدارهای کهنه را میبیند. به همین خاطر آنها را در یک شیء تغییرپذیر نگه میداریم و بعد از هر commit بهروزش میکنیم:
function useStore<T>(subscribe, getSnapshot: () => T): T {
const value = getSnapshot();
// inst یک شیء ثابت است که بین رندرها زنده میماند.
const [{ inst }, forceUpdate] = useState({ inst: { value, getSnapshot } });
useLayoutEffect(() => {
inst.value = value;
inst.getSnapshot = getSnapshot;
if (snapshotChanged(inst)) forceUpdate({ inst });
}, [subscribe, value, getSnapshot]);
useEffect(() => {
if (snapshotChanged(inst)) forceUpdate({ inst });
return subscribe(() => {
if (snapshotChanged(inst)) forceUpdate({ inst });
});
}, [subscribe]);
return value;
}
function snapshotChanged<T>(inst: { value: T; getSnapshot: () => T }) {
try {
return !Object.is(inst.value, inst.getSnapshot());
} catch {
return true;
}
}
این نسخه در عمل همان چیزی است که تیم ریاکت در پکیج use-sync-external-store/shim برای نسخههای قبل از React 18 منتشر کرد. هر تکهاش دلیلی دارد:
useState({ inst })بهجای useRef.instتا آخر عمر کامپوننت همان شیء میماند، ولیforceUpdate({ inst })هر بار یک شیء تازه دور آن میپیچد. شیء تازه باObject.isبرابر قبلی نیست، پس ریاکت حتماً رندر میکند. یعنی یک state و یک forceUpdate را با هم داریم.- useLayoutEffect برای بهروز کردن
inst. بعد از هر commit، مقدار و تابعی که واقعاً روی صفحه نشسته را درinstمینویسد تا listener با دادهی درست مقایسه کند. layout effect انتخاب شده چون قبل از paint اجرا میشود؛ اگر store بین رندر و commit تغییر کرده باشد، همینجا معلوم میشود و رندر مجدد قبل از اینکه کاربر مقدار کهنه را ببیند اتفاق میافتد. - بررسی دوباره درست بعد از subscribe. این خط
if (snapshotChanged(inst))اول useEffect جواب باگ اول است. هر تغییری که بین رندر و ثبت listener اتفاق افتاده باشد، اینجا دیده میشود. در مثال localStorage، Counter بلافاصله بعد از subscribe میبیند که ۰ دیگر با ۷ برابر نیست و دوباره رندر میشود. - try/catch در مقایسه. getSnapshot ممکن است با state جدید store خطا بدهد (مثلاً آیتمی که حذف شده). بهجای اینکه خطا داخل listener و بیرون از ریاکت گم شود، رندر مجدد را راه میاندازیم تا خطا در رندر پرتاب شود و به Error Boundary برسد.
- وابستگیهای effectها. useEffect فقط به
subscribeوابسته است؛ عوض شدن getSnapshot نباید unsubscribe و subscribe دوباره راه بیندازد، چونinstآخرین نسخهاش را دارد. به همین دلیل در نوشتهی قبلیstore.subscribeرا به شکل arrow function روی کلاس تعریف کردم تا ارجاعش ثابت بماند؛ اگر در هر رندر تابع تازهای پاس بدهید، هوک در هر رندر unsubscribe و دوباره subscribe میکند.
حالا دوباره پنج شرط را مرور کنیم: مقدار در هر رندر تازه خوانده میشود، تغییرِ مؤثر رندر میکند، تغییر بیاثر رندر نمیکند، چیزی بین رندر و subscribe گم نمیشود، و تابعهای جدید دیده میشوند.
چرا getSnapshot باید مقدار cacheشده برگرداند
با این پیادهسازی میشود دید هشدار معروف ریاکت از کجا میآید. فرض کنید store یک شیء نگه دارد و getSnapshot را اینطور بنویسید:
const getSnapshot = () => ({ count: store.get().count });
هر بار که صدا زده شود شیء تازهای میسازد. در layout effect، snapshotChanged مقدار رندرشده را با یک شیء تازه مقایسه میکند، Object.is هیچوقت true نمیشود، forceUpdate صدا زده میشود، رندر بعدی باز شیء تازهای میسازد و این حلقه تا وقتی ریاکت خطای Maximum update depth exceeded بدهد ادامه پیدا میکند.
نسخهی اصلی در حالت توسعه دقیقاً همین را زودتر چک میکند: getSnapshot را دو بار پشت سر هم صدا میزند و اگر دو نتیجه با Object.is برابر نباشند هشدار میدهد:
The result of getSnapshot should be cached to avoid an infinite loop
به نسخهی خودمان هم میشود اضافهاش کرد:
if (process.env.NODE_ENV !== "production") {
if (!Object.is(getSnapshot(), getSnapshot())) {
console.error("getSnapshot should return a cached value");
}
}
راه درستش این است که یا خودِ مقدار ذخیرهشده در store را برگردانید (() => store.get())، یا اگر لازم است چیزی از آن مشتق کنید، نتیجه را تا وقتی ورودی عوض نشده cache کنید. پکیج use-sync-external-store/with-selector برای همین کار نوشته شده.
SSR و getServerSnapshot
نسخهی اصلی آرگومان سومی هم دارد. روی سرور effectها اجرا نمیشوند، پس هوک ما آنجا فقط getSnapshot() را صدا میزند و برمیگرداند. مشکل در hydrate پیدا میشود: اگر مقدار store در مرورگر با مقداری که سرور رندر کرده فرق داشته باشد (مثلاً چیزی که از localStorage آمده)، HTML سرور با اولین رندر کلاینت جور نیست و ریاکت خطای hydration میدهد.
نسخهی اصلی این را اینطور حل میکند که هم روی سرور و هم در طول hydrate از getServerSnapshot استفاده میکند و بعد، اگر مقدار کلاینت فرق داشت، یک رندر دیگر انجام میدهد. کد کاربر راه مستقیمی برای فهمیدن «الان وسط hydrate هستم» ندارد. نزدیکترین تقریب یک flag است که بعد از اولین effect روشن میشود:
function useHydrated() {
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
return hydrated;
}
// داخل useStore:
const hydrated = useHydrated();
const value = hydrated ? getSnapshot() : getServerSnapshot();
ولی این تقریب هزینه دارد: هر کامپوننتی که بعد از بارگذاری صفحه mount شود (و اصلاً hydrate نمیشود) هم یک رندر اضافه با مقدار سرور میخورد. تفاوت نسخهی اصلی با نسخهی ما دقیقاً همینجاست؛ ریاکت از داخل میداند کدام رندر hydrate است و کدام نیست.
چیزی که از کد کاربر برنمیآید: tearing
هوکی که ساختیم در رندر همگام (synchronous) درست کار میکند، ولی کاملاً معادل نسخهی اصلی نیست. دلیلش مشکل tearing است.
در رندر concurrent، مثلاً وقتی آپدیتی داخل startTransition باشد، ریاکت میتواند رندر درخت را وسط کار متوقف کند، به کار مهمتری برسد و بعد ادامه دهد. اگر store در همین وقفه تغییر کند، ویجتهایی که قبل از وقفه رندر شدهاند مقدار قدیمی را دارند و بقیه مقدار جدید را. هوک ما این ناهماهنگی را فقط بعد از commit در layout effect میفهمد و درستش میکند. تا آن لحظه درخت ناهماهنگ commit شده و layout effectهای کامپوننتهای فرزند همان دادهی ناهماهنگ را دیدهاند.
نسخهی اصلی دو کار انجام میدهد که از بیرون ریاکت ممکن نیست:
- بررسی سازگاری قبل از commit. وقتی رندر concurrent تمام شد و هنوز چیزی commit نشده، ریاکت برای همهی کامپوننتهایی که در این رندر از store خواندهاند دوباره getSnapshot را صدا میزند. اگر حتی یکی عوض شده باشد، آن رندر را دور میریزد و درخت را این بار بهصورت همگام و بدون وقفه دوباره رندر میکند. درخت ناهماهنگ هیچوقت commit نمیشود.
- آپدیتهای store همیشه همگاماند. تغییری که از طریق store میرسد وارد transition نمیشود و ریاکت آن را time-slice نمیکند. این همان هزینهای است که مستندات ریاکت هم به آن اشاره میکنند: اگر تغییر store باعث suspend شود، ریاکت نمیتواند مثل state معمولی UI قبلی را نگه دارد و fallback را نشان میدهد.
این دو کار به ساختار داخلی scheduler ریاکت دسترسی لازم دارند و با هوکهای عمومی قابل پیادهسازی نیستند. برای همین useSyncExternalStore در React 18 به هستهی ریاکت اضافه شد و shim فقط برای نسخههای قدیمیتر باقی ماند؛ در React 18 به بعد، همان shim هم مستقیم نسخهی داخلی را برمیگرداند.
امتحانش روی ویجتها
برای اینکه مطمئن شوم این هوک رفتار نوشتهی قبلی را حفظ میکند، در ویجتها useSyncExternalStore را با useStore خودمان عوض کردم:
const Widget = memo(function Widget({ id }: { id: string }) {
const getSnapshot = useCallback(() => store.getSnapshot(id), [id]);
const value = useStore(store.subscribe, getSnapshot);
return value === null ? <Skeleton /> : <span>{value}</span>;
});
با هر store.set همهی listenerها صدا زده میشوند، ولی snapshotChanged فقط برای ویجتی true برمیگرداند که دادهاش عوض شده، و فقط همان forceUpdate میکند. همان شرط سوم که کل نوشتهی قبلی به آن تکیه داشت، این بار با کدی که خودمان نوشتیم.
جمعبندی
کل نسخهی نهایی کمتر از سی خط است و هر خطش جواب یکی از این سؤالهاست: مقدار را از کجا بخوانیم (از getSnapshot در هر رندر، نه از state)، کِی رندر کنیم (فقط وقتی Object.is بگوید عوض شده)، چه تغییری ممکن است گم شود (تغییری که بین رندر و subscribe رخ داده) و listener از کجا بداند آخرین مقدار چه بوده (از شیء inst که بعد از هر commit بهروز میشود).
در پروژهی واقعی از همان useSyncExternalStore خود ریاکت استفاده کنید. ولی دفعهی بعد که هشدار cache نشدن getSnapshot را دیدید یا subscribe شما در هر رندر دوباره اجرا شد، میدانید زیر آن هوک دقیقاً چه اتفاقی میافتد.