بررسی عمیق هوک useSyncExternalStore ریاکت برای همگامسازی استورهای داده خارجی، شامل استراتژیهای پیادهسازی، ملاحظات عملکردی و موارد استفاده پیشرفته.
React useSyncExternalStore: تسلط بر همگامسازی استورهای خارجی
در اپلیکیشنهای مدرن ریاکت، مدیریت مؤثر وضعیت (state) بسیار حیاتی است. در حالی که ریاکت راهحلهای داخلی مدیریت وضعیت مانند useState و useReducer را ارائه میدهد، ادغام با منابع داده خارجی یا کتابخانههای مدیریت وضعیت شخص ثالث نیازمند رویکردی پیچیدهتر است. اینجاست که useSyncExternalStore وارد عمل میشود.
useSyncExternalStore چیست؟
useSyncExternalStore یک هوک ریاکت است که در نسخه ۱۸ معرفی شد و به شما اجازه میدهد تا به منابع داده خارجی مشترک شوید (subscribe) و از آنها بخوانید، به روشی که با رندر همزمان (concurrent rendering) سازگار باشد. این موضوع به ویژه هنگام کار با دادههایی که مستقیماً توسط ریاکت مدیریت نمیشوند، اهمیت دارد، مانند:
- کتابخانههای مدیریت وضعیت شخص ثالث: Redux، Zustand، Jotai و غیره.
- APIهای مرورگر:
localStorage،IndexedDBو غیره. - منابع داده خارجی: Server-sent events، WebSockets و غیره.
پیش از useSyncExternalStore، همگامسازی استورهای خارجی میتوانست منجر به "تکه تکه شدن" (tearing) و عدم هماهنگی شود، به خصوص با ویژگیهای رندر همزمان ریاکت. این هوک با ارائه روشی استاندارد و بهینه برای اتصال دادههای خارجی به کامپوننتهای ریاکت شما، این مشکلات را برطرف میکند.
چرا از useSyncExternalStore استفاده کنیم؟ مزایا و فواید
استفاده از useSyncExternalStore چندین مزیت کلیدی ارائه میدهد:
- ایمنی در حالت همزمانی: تضمین میکند که کامپوننت شما همیشه نمای یکپارچهای از استور خارجی را نمایش میدهد، حتی در حین رندرهای همزمان. این از مشکلات tearing جلوگیری میکند که در آن بخشهایی از UI شما ممکن است دادههای ناهماهنگ نشان دهند.
- عملکرد: برای عملکرد بهینه شده است و رندرهای مجدد غیرضروری را به حداقل میرساند. این هوک از مکانیزمهای داخلی ریاکت برای اشتراک مؤثر در تغییرات و بهروزرسانی کامپوننت فقط در مواقع ضروری استفاده میکند.
- API استاندارد: یک API یکپارچه و قابل پیشبینی برای تعامل با استورهای خارجی، صرفنظر از پیادهسازی زیربنایی آنها، فراهم میکند.
- کاهش کدهای تکراری (Boilerplate): فرآیند اتصال به استورهای خارجی را ساده میکند و میزان کدهای سفارشی که باید بنویسید را کاهش میدهد.
- سازگاری: به طور یکپارچه با طیف گستردهای از منابع داده خارجی و کتابخانههای مدیریت وضعیت کار میکند.
useSyncExternalStore چگونه کار میکند: یک بررسی عمیق
هوک useSyncExternalStore سه آرگومان دریافت میکند:
subscribe(callback: () => void): () => void: یک تابع که یک callback را ثبت میکند تا هنگام تغییر استور خارجی مطلع شود. این تابع باید یک تابع دیگر برای لغو اشتراک (unsubscribe) برگرداند. به این ترتیب ریاکت متوجه میشود که استور داده جدیدی دارد.getSnapshot(): T: یک تابع که یک snapshot (تصویر لحظهای) از دادههای استور خارجی را برمیگرداند. این snapshot باید یک مقدار ساده و غیرقابل تغییر (immutable) باشد که ریاکت بتواند برای تشخیص تغییر داده از آن استفاده کند.getServerSnapshot?(): T(اختیاری): یک تابع که snapshot اولیه دادهها را در سرور برمیگرداند. این برای رندر سمت سرور (SSR) استفاده میشود تا از هماهنگی بین سرور و کلاینت اطمینان حاصل شود. اگر ارائه نشود، ریاکت در حین رندر سمت سرور ازgetSnapshot()استفاده میکند که ممکن است برای همه سناریوها ایدهآل نباشد.
در اینجا خلاصهای از نحوه کار این آرگومانها با یکدیگر آورده شده است:
- هنگامی که کامپوننت mount میشود،
useSyncExternalStoreتابعsubscribeرا برای ثبت یک callback فراخوانی میکند. - هنگامی که استور خارجی تغییر میکند، callback ثبتشده از طریق
subscribeرا فراخوانی میکند. - این callback به ریاکت اطلاع میدهد که کامپوننت نیاز به رندر مجدد دارد.
- در حین رندر،
useSyncExternalStoreتابعgetSnapshotرا برای دریافت آخرین دادهها از استور خارجی فراخوانی میکند. - ریاکت snapshot فعلی را با snapshot قبلی مقایسه میکند. اگر متفاوت باشند، کامپوننت با دادههای جدید بهروزرسانی میشود.
- هنگامی که کامپوننت unmount میشود، تابع لغو اشتراک که توسط
subscribeبازگردانده شده، برای جلوگیری از نشت حافظه (memory leaks) فراخوانی میشود.
مثال پیادهسازی پایه: ادغام با localStorage
بیایید با یک مثال ساده نحوه استفاده از useSyncExternalStore را نشان دهیم: خواندن و نوشتن یک مقدار در localStorage.
import { useSyncExternalStore } from 'react';
function getLocalStorageItem(key: string): string | null {
try {
return localStorage.getItem(key);
} catch (error) {
console.error("Error accessing localStorage:", error);
return null; // Handle potential errors like `localStorage` being unavailable.
}
}
function useLocalStorage(key: string): [string | null, (value: string) => void] {
const subscribe = (callback: () => void) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
};
const getSnapshot = () => getLocalStorageItem(key);
const serverSnapshot = () => null; // Or a default value if appropriate for your SSR setup
const value = useSyncExternalStore(subscribe, getSnapshot, serverSnapshot);
const setValue = (newValue: string) => {
try {
localStorage.setItem(key, newValue);
// Dispatch a storage event on the current window to trigger updates in other tabs.
window.dispatchEvent(new StorageEvent('storage', {
key: key,
newValue: newValue,
storageArea: localStorage,
} as StorageEventInit));
} catch (error) {
console.error("Error setting localStorage:", error);
}
};
return [value, setValue];
}
function MyComponent() {
const [name, setName] = useLocalStorage('name');
return (
<div>
<p>Hello, {name || 'World'}</p>
<input
type="text"
value={name || ''}
onChange={(e) => setName(e.target.value)}
/>
</div>
);
}
export default MyComponent;
توضیح:
getLocalStorageItem: یک تابع کمکی برای بازیابی ایمن مقدار ازlocalStorage، با مدیریت خطاهای احتمالی.useLocalStorage: یک هوک سفارشی که منطق تعامل باlocalStorageرا با استفاده ازuseSyncExternalStoreکپسوله میکند.subscribe: به رویداد'storage'گوش میدهد که هنگام تغییرlocalStorageدر یک تب یا پنجره دیگر فعال میشود. نکته مهم این است که ما پس از تنظیم مقدار جدید، یک رویداد storage را به صورت دستی ارسال میکنیم تا بهروزرسانیها را در *همان* پنجره نیز فعال کنیم.getSnapshot: مقدار فعلی را ازlocalStorageبرمیگرداند.serverSnapshot: برای رندر سمت سرور، مقدارnull(یا یک مقدار پیشفرض) را برمیگرداند.setValue: مقدار را درlocalStorageبهروز میکند و یک رویداد storage برای اطلاعرسانی به تبهای دیگر ارسال میکند.MyComponent: یک کامپوننت ساده که از هوکuseLocalStorageبرای نمایش و بهروزرسانی یک نام استفاده میکند.
ملاحظات مهم برای localStorage:
- مدیریت خطا: همیشه دسترسی به
localStorageرا در بلوکهایtry...catchقرار دهید تا خطاهای احتمالی مانند غیرفعال بودن یا در دسترس نبودنlocalStorage(مثلاً در حالت مرور خصوصی) را مدیریت کنید. - رویدادهای Storage: رویداد
'storage'فقط زمانی فعال میشود کهlocalStorageدر یک تب یا پنجره *دیگر* تغییر کند، نه در همان پنجره. بنابراین، ما پس از تنظیم مقدار، یکStorageEventجدید را به صورت دستی ارسال میکنیم. - سریالسازی داده:
localStorageفقط رشتهها را ذخیره میکند. ممکن است لازم باشد ساختارهای داده پیچیده را با استفاده ازJSON.stringifyوJSON.parseسریالسازی و دیسریالسازی کنید. - امنیت: مراقب دادههایی که در
localStorageذخیره میکنید باشید، زیرا برای کدهای جاوا اسکریپت در همان دامنه قابل دسترسی است. اطلاعات حساس نباید درlocalStorageذخیره شوند.
موارد استفاده و مثالهای پیشرفته
۱. ادغام با Zustand (یا کتابخانههای مدیریت وضعیت دیگر)
ادغام useSyncExternalStore با یک کتابخانه مدیریت وضعیت سراسری مانند Zustand یک مورد استفاده رایج است. در اینجا یک مثال آورده شده است:
import { useSyncExternalStore } from 'react';
import { create } from 'zustand';
interface BearState {
bears: number
increase: (by: number) => void
}
const useStore = create<BearState>((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by }))
}))
function BearCounter() {
const bears = useSyncExternalStore(
useStore.subscribe,
useStore.getState,
() => ({ bears: 0, increase: () => {} }) // Server snapshot, provide default state
).bears
return <h1>{bears} bears around here!</h1>
}
function Controls() {
const increase = useStore(state => state.increase)
return (<button onClick={() => increase(1)}>one bear</button>)
}
export { BearCounter, Controls }
توضیح:
- ما از Zustand برای مدیریت وضعیت سراسری استفاده میکنیم.
useStore.subscribe: این تابع به استور Zustand مشترک میشود و با تغییر وضعیت استور، باعث رندر مجدد میشود.useStore.getState: این تابع وضعیت فعلی استور Zustand را برمیگرداند.- پارامتر سوم یک وضعیت پیشفرض برای رندر سمت سرور (SSR) فراهم میکند و تضمین میکند که کامپوننت قبل از اینکه جاوا اسکریپت سمت کلاینت کنترل را به دست بگیرد، به درستی در سرور رندر شود.
- این کامپوننت تعداد خرسها را با استفاده از
useSyncExternalStoreدریافت و رندر میکند. - کامپوننت
Controlsنحوه استفاده از یک setter در Zustand را نشان میدهد.
۲. ادغام با Server-Sent Events (SSE)
useSyncExternalStore میتواند برای بهروزرسانی مؤثر کامپوننتها بر اساس دادههای بلادرنگ از سرور با استفاده از Server-Sent Events (SSE) استفاده شود.
import { useSyncExternalStore, useState, useEffect, useCallback } from 'react';
function useSSE(url: string) {
const [data, setData] = useState(null);
const [eventSource, setEventSource] = useState(null);
useEffect(() => {
const newEventSource = new EventSource(url);
setEventSource(newEventSource);
newEventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data);
setData(parsedData);
} catch (error) {
console.error("Error parsing SSE data:", error);
}
};
newEventSource.onerror = (error) => {
console.error("SSE error:", error);
};
return () => {
newEventSource.close();
setEventSource(null);
};
}, [url]);
const subscribe = useCallback((callback: () => void) => {
if (eventSource) {
eventSource.addEventListener('message', callback);
}
return () => {
if (eventSource) {
eventSource.removeEventListener('message', callback);
}
};
}, [eventSource]);
const getSnapshot = useCallback(() => data, [data]);
const serverSnapshot = useCallback(() => null, []);
const value = useSyncExternalStore(subscribe, getSnapshot, serverSnapshot);
return value;
}
function RealTimeDataComponent() {
const realTimeData = useSSE('/api/sse'); // Replace with your SSE endpoint
if (!realTimeData) {
return <p>Loading...</p>;
}
return <div><p>Real-time Data: {JSON.stringify(realTimeData)}</p></div>;
}
export default RealTimeDataComponent;
توضیح:
useSSE: یک هوک سفارشی که یک اتصال SSE به یک URL مشخص برقرار میکند.subscribe: یک event listener به شیءEventSourceاضافه میکند تا از پیامهای جدید سرور مطلع شود. ازuseCallbackاستفاده میکند تا اطمینان حاصل شود که تابع callback در هر رندر مجدداً ایجاد نمیشود.getSnapshot: آخرین دادههای دریافت شده از جریان SSE را برمیگرداند.serverSnapshot: برای رندر سمت سرور،nullبرمیگرداند.RealTimeDataComponent: کامپوننتی که از هوکuseSSEبرای نمایش دادههای بلادرنگ استفاده میکند.
۳. ادغام با IndexedDB
همگامسازی کامپوننتهای ریاکت با دادههای ذخیره شده در IndexedDB با استفاده از useSyncExternalStore.
import { useSyncExternalStore, useState, useEffect, useCallback } from 'react';
interface IDBData {
id: number;
name: string;
}
async function getAllData(): Promise {
return new Promise((resolve, reject) => {
const request = indexedDB.open('myDataBase', 1); // Replace with your database name and version
request.onerror = (event) => {
console.error("IndexedDB open error:", event);
reject(event);
};
request.onsuccess = (event) => {
const db = (event.target as IDBRequest).result as IDBDatabase;
const transaction = db.transaction(['myDataStore'], 'readonly'); // Replace with your store name
const objectStore = transaction.objectStore('myDataStore');
const getAllRequest = objectStore.getAll();
getAllRequest.onsuccess = (event) => {
const data = (event.target as IDBRequest).result as IDBData[];
resolve(data);
};
getAllRequest.onerror = (event) => {
console.error("IndexedDB getAll error:", event);
reject(event);
};
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBRequest).result as IDBDatabase;
db.createObjectStore('myDataStore', { keyPath: 'id' });
};
});
}
function useIndexedDBData(): IDBData[] | null {
const [data, setData] = useState(null);
const [dbInitialized, setDbInitialized] = useState(false);
useEffect(() => {
const initDB = async () => {
try{
await getAllData();
setDbInitialized(true);
} catch (e) {
console.error("IndexedDB initialization failed", e);
}
}
initDB();
}, []);
const subscribe = useCallback((callback: () => void) => {
// Debounce the callback to prevent excessive re-renders.
let timeoutId: NodeJS.Timeout;
const debouncedCallback = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(callback, 50); // Adjust the debounce delay as needed
};
const handleVisibilityChange = () => {
// Re-fetch data when the tab becomes visible again
if (document.visibilityState === 'visible') {
debouncedCallback();
}
};
window.addEventListener('focus', debouncedCallback);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', debouncedCallback);
document.removeEventListener('visibilitychange', handleVisibilityChange);
clearTimeout(timeoutId);
};
}, []);
const getSnapshot = useCallback(() => {
// Fetch the latest data from IndexedDB every time getSnapshot is called
getAllData().then(newData => setData(newData));
return data;
}, [data]);
const serverSnapshot = useCallback(() => null, []);
return useSyncExternalStore(subscribe, getSnapshot, serverSnapshot);
}
function IndexedDBComponent() {
const data = useIndexedDBData();
if (!data) {
return <p>Loading data from IndexedDB...</p>;
}
return (
<div>
<h2>Data from IndexedDB:</h2>
<ul>
{data.map((item) => (
<li key={item.id}>{item.name} (ID: {item.id})</li>
))}
</ul>
</div>
);
}
export default IndexedDBComponent;
توضیح:
getAllData: یک تابع ناهمگام (asynchronous) که تمام دادهها را از استور IndexedDB بازیابی میکند.useIndexedDBData: یک هوک سفارشی که ازuseSyncExternalStoreبرای اشتراک در تغییرات IndexedDB استفاده میکند.subscribe: شنوندههایی برای تغییرات visibility و focus تنظیم میکند تا دادهها را از IndexedDB بهروز کند و از یک تابع debounce برای جلوگیری از بهروزرسانیهای بیش از حد استفاده میکند.getSnapshot: با فراخوانی `getAllData()` snapshot فعلی را دریافت کرده و سپس `data` را از state برمیگرداند.serverSnapshot: برای رندر سمت سرور،nullبرمیگرداند.IndexedDBComponent: کامپوننتی که دادههای IndexedDB را نمایش میدهد.
ملاحظات مهم برای IndexedDB:
- عملیات ناهمگام: تعاملات با IndexedDB ناهمگام هستند، بنابراین باید ماهیت ناهمگام بازیابی و بهروزرسانی دادهها را با دقت مدیریت کنید.
- مدیریت خطا: مدیریت خطای قوی برای رسیدگی به مشکلات احتمالی دسترسی به پایگاه داده، مانند پیدا نشدن پایگاه داده یا خطاهای مجوز، پیادهسازی کنید.
- نسخهبندی پایگاه داده: نسخههای پایگاه داده را با دقت با استفاده از رویداد
onupgradeneededمدیریت کنید تا از سازگاری دادهها با تکامل برنامه شما اطمینان حاصل شود. - عملکرد: عملیات IndexedDB میتواند نسبتاً کند باشد، به خصوص برای مجموعه دادههای بزرگ. کوئریها و ایندکسگذاری را برای بهبود عملکرد بهینه کنید.
ملاحظات عملکردی
در حالی که useSyncExternalStore برای عملکرد بهینه شده است، هنوز ملاحظاتی وجود دارد که باید در نظر داشته باشید:
- به حداقل رساندن تغییرات Snapshot: اطمینان حاصل کنید که تابع
getSnapshotفقط زمانی یک snapshot جدید برمیگرداند که دادهها واقعاً تغییر کرده باشند. از ایجاد بیمورد اشیاء یا آرایههای جدید خودداری کنید. برای بهینهسازی ایجاد snapshot، از تکنیکهای memoization استفاده کنید. - بهروزرسانیهای دستهای (Batch Updates): در صورت امکان، بهروزرسانیهای استور خارجی را به صورت دستهای انجام دهید تا تعداد رندرهای مجدد کاهش یابد. به عنوان مثال، اگر چندین ویژگی را در استور بهروز میکنید، سعی کنید همه آنها را در یک تراکنش واحد بهروز کنید.
- Debouncing/Throttling: اگر استور خارجی به طور مکرر تغییر میکند، بهروزرسانیهای کامپوننت ریاکت را debounce یا throttle کنید. این میتواند از رندرهای مجدد بیش از حد جلوگیری کرده و عملکرد را بهبود بخشد. این روش به ویژه برای استورهای ناپایدار مانند تغییر اندازه پنجره مرورگر مفید است.
- مقایسه سطحی (Shallow Comparison): اطمینان حاصل کنید که در
getSnapshotمقادیر اولیه (primitive) یا اشیاء غیرقابل تغییر (immutable) را برمیگردانید تا ریاکت بتواند به سرعت با استفاده از مقایسه سطحی تشخیص دهد که آیا دادهها تغییر کردهاند یا خیر. - بهروزرسانیهای شرطی: در مواردی که استور خارجی به طور مکرر تغییر میکند اما کامپوننت شما فقط باید به تغییرات خاصی واکنش نشان دهد، بهروزرسانیهای شرطی را در تابع `subscribe` پیادهسازی کنید تا از رندرهای مجدد غیرضروری جلوگیری شود.
مشکلات رایج و عیبیابی
- مشکلات Tearing: اگر پس از استفاده از
useSyncExternalStoreهنوز با مشکلات tearing مواجه هستید، دوباره بررسی کنید که تابعgetSnapshotشما نمای یکپارچهای از دادهها را برمیگرداند و تابعsubscribeبه درستی ریاکت را از تغییرات مطلع میکند. اطمینان حاصل کنید که دادهها را مستقیماً در تابعgetSnapshotتغییر نمیدهید. - حلقههای بینهایت: یک حلقه بینهایت ممکن است زمانی رخ دهد که تابع
getSnapshotهمیشه یک مقدار جدید برگرداند، حتی زمانی که دادهها تغییر نکردهاند. این میتواند در صورت ایجاد بیمورد اشیاء یا آرایههای جدید اتفاق بیفتد. اطمینان حاصل کنید که اگر دادهها تغییر نکردهاند، همان مقدار را برمیگردانید. - عدم وجود رندر سمت سرور: اگر از رندر سمت سرور استفاده میکنید، حتماً یک تابع
getServerSnapshotارائه دهید تا اطمینان حاصل شود که کامپوننت به درستی در سرور رندر میشود. این تابع باید وضعیت اولیه استور خارجی را برگرداند. - لغو اشتراک نادرست: همیشه اطمینان حاصل کنید که به درستی از استور خارجی در تابعی که توسط
subscribeبازگردانده میشود، لغو اشتراک میکنید. عدم انجام این کار میتواند منجر به نشت حافظه شود. - استفاده نادرست با Concurrent Mode: اطمینان حاصل کنید که استور خارجی شما با Concurrent Mode سازگار است. از ایجاد تغییرات در استور خارجی در حین رندر شدن ریاکت خودداری کنید. تغییرات باید همزمان و قابل پیشبینی باشند.
نتیجهگیری
useSyncExternalStore ابزاری قدرتمند برای همگامسازی کامپوننتهای ریاکت با استورهای داده خارجی است. با درک نحوه کار آن و پیروی از بهترین شیوهها، میتوانید اطمینان حاصل کنید که کامپوننتهای شما دادههای یکپارچه و بهروز را نمایش میدهند، حتی در سناریوهای پیچیده رندر همزمان. این هوک ادغام با منابع داده مختلف، از کتابخانههای مدیریت وضعیت شخص ثالث گرفته تا APIهای مرورگر و جریانهای داده بلادرنگ را ساده میکند و منجر به اپلیکیشنهای ریاکت قویتر و با عملکرد بهتر میشود. به یاد داشته باشید که همیشه خطاهای احتمالی را مدیریت کنید، عملکرد را بهینه کنید و اشتراکها را با دقت مدیریت کنید تا از مشکلات رایج جلوگیری کنید.