Race Conditions في React — لما الطلب القديم يسبق الجديد ويكتب فوقه
Race Conditions في React — لما الطلب القديم يسبق الجديد ويكتب فوقه
غالباً لو اشتغلت بـ React وعملت fetch جوه useEffect، هيكون عدّى عليك المشهد ده (أو هيقابلك قريب): اليوزر بيقلّب بين التابات بسرعة، أو بيغيّر فلتر في لوحة التحكم، أو بيكتب في السيرش، وفجأة تلاقي الشاشة عارضة داتا غلط خالص — مش عشان الباك إند فيه مشكلة، لأ، عشان فيه طلب قديم اتأخر في الطريق ولما وصل كتب فوق الطلب الجديد.
الـ Bug ده صعب في اكتشافه لأنه صامت على الآخر:
- مفيش أي Error في الكونسول.
- الـ Network Tab كلها مخضّرة وبترجع
200 OK. - الكود شكله نضيف ومفهوش غلطة.
ومع ذلك، تلاقي مهندس الـ QA (أو الـ Tester) أو العميل بيكلمك يقولك: “أنا واقف على فلتر الأسبوع، بس الأرقام اللي قدامي دي بتاعة السنة اللي فاتت”.
المشكلة دي اسمها Race Condition (حالة سباق). تعال نفهمها من جذورها وبأمثلة حقيقية من الشغل، ونشوف إزاي نقفل الباب ده خالص.
القصة بتبدأ إزاي؟
تخيل إنك شغال على Dashboard لمبيعات شركة، وفيها زرارين اليوزر بيبدّل بينهم:
- تقرير السنة (
year): ده بيجيب داتا ضخمة وحسابات كتير من الداتابيز، فالسيرفر بياخد حوالي 4 ثواني عشان يرد. - تقرير آخر 7 أيام (
week): ده خفيف وسريع، السيرفر بيرد في نص ثانية بس.
شوف بقى اللي اليوزر بيعمله في الطبيعي:
- فتح الصفحة وداس على “السنة” بالغلط، فطلع أول طلب للسيرفر (Request 1).
- بعدها بثانية واحدة، افتكر إنه عايز “آخر 7 أيام” فداس عليها، وطلع الطلب التاني (Request 2).
اليوزر دلوقتي قاعد مستني يشوف أرقام الأسبوع. بس تعال نبص على اللي بيحصل ورا الكواليس بالثانية:
الزمن اليوزر والشاشة الشبكة (Network)
─────────────────────────────────────────────────────────────────────────────────────
00:00s اليوزر داس على "السنة" (year) ───► [Request 1 طلع] (بطيء.. هياخد 4 ثواني)
00:01s اليوزر غيّر لـ "الأسبوع" (week) ───► [Request 2 طلع] (سريع.. هياخد 0.5 ثانية)
00:01.5s الشاشة عرضت "الأسبوع" (كله تمام) ◄─── [Request 2 خلّص الأول] وعمل setReport
00:04s الشاشة قلبت لوحدها لـ "السنة"! ◄─── [Request 1 القديم وصل أخيراً] وعمل setReport
في الثانية الرابعة، اليوزر واقف بعينه على زرار “آخر 7 أيام” وهو اللي منوّر في الشاشة، بس الأرقام والجداول اللي قدامه هي بتاعة “السنة”!
الفكرة كلها في سطر واحدالترتيب اللي الطلبات بتتبعت بيه، مش شرط يكون هو نفس الترتيب اللي الطلبات بتخلص وترجع بيه. الشاشة المفروض تعرض نتيجة آخر طلب اليوزر طلبه، مش آخر طلب وصل من السيرفر.
الكود اللي كلنا كتبناه وبيعمل المشكلة دي
بص على الكود ده، يبان بريء ومثالي، بس هو اللي بيعمل السباق ده:
import { useEffect, useState } from 'react';
function SalesReport({ period }) {
const [report, setReport] = useState(null);
useEffect(() => {
fetch(`/api/reports?period=${period}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
// المشكلة كلها هنا:
// أي طلب يخلص في أي وقت هيدخل يعمل setReport
// من غير ما يسأل: "هو اليوزر لسه واقف على نفس الـ period ولا غيّرها؟"
setReport(data);
});
}, [period]);
if (!report) return <p>Loading...</p>;
return <ReportView data={report} />;
}
طب ليه React مبتوقفش الطلب القديم لوحدها؟
لما الـ period بتتغير من year لـ week، React بتعمل Render جديد وبتشغّل useEffect جديد. بس الطلب القديم اللي طلع في المرة الأولى لسه شغال في المتصفح في الخلفية، ومحدش قاله يقف. أول ما السيرفر يرد عليه بعد 4 ثواني، هيكمّل الكود بتاعه عادي جداً وينادي setReport(data) ويمسح الداتا الجديدة اللي ع الشاشة.
تفتكر ليه كاتبينif (!res.ok)؟معلومة سريعة على الماشي: الـ
fetchمش بترمي Error لو السيرفر رجّع404أو500. هي بتعتبر طالما السيرفر رد يبقى الطلب نجح! فلو مفحصتشres.okبإيدك، الكود هياخد رسالة الخطأ بتاعة السيرفر ويحاول يعرضها كأنها بيانات مبيعات عادية.
الحل الأول (الرسمي والبسيط): الـ ignore Flag
فريق React في الـ Documentation الرسمي بيحل الموضوع ده بحركة ذكية وبسيطة جداً: متغير Boolean اسمه ignore بنحطه جوه الـ useEffect، ولما اليوزر يغير رأيه بنخليه true جوه دالة الـ Cleanup.
تعال نشوف الكود الأول، وبعدين نفهم إزاي بيشتغل في الذاكرة:
useEffect(() => {
let ignore = false;
fetch(`/api/reports?period=${period}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
// قبل ما نحدّث الشاشة، بنسأل: الطلب ده لسه مهم ولا اتلغى؟
if (!ignore) {
setReport(data);
}
});
// الـ Cleanup: بتشتغل أوتوماتيك أول ما الـ period تتغير
return () => {
ignore = true;
};
}, [period]);
سؤال بيلخبط ناس كتير: إزاي لما ignore بتبقى true مش بتوقف الطلب الجديد هو كمان؟
أول ما تشوف الكود ده ممكن تقول: “طب ما أنا لما أخلي ignore = true، كده الطلب الجديد كمان مش هيدخل جوه الـ if!”
الحقيقة لأ، وده بفضل الـ Closure في الجافاسكريبت. كل مرة الـ period بتتغير والـ useEffect بيشتغل، الجافاسكريبت بتفتحله “نسخة مستقلة في الذاكرة” بمتغير ignore خاص بالمرة دي بس، ملوش أي علاقة بالنسخة اللي قبلها ولا اللي بعدها:
| الخطوة | نسخة الريندر الأول (period = 'year') | نسخة الريندر التاني (period = 'week') |
|---|---|---|
1. اليوزر فتح على year | اتعمل متغير ignore قيمته false، وطلع الطلب الأول. | (لسه متعملتش) |
2. اليوزر غيّر لـ week | React شغلت الـ Cleanup بتاع النسخة الأولى، فخلت الـ ignore بتاعها هو بس بـ true. | اتفتحت نسخة جديدة خالص بمتغير ignore جديد قيمته false، وطلع الطلب التاني. |
3. طلب week رجع في نص ثانية | (لسه مستني السيرفر) | بيبص على الـ ignore بتاعه بيلاقيه false، فبيعرض الداتا على الشاشة. |
4. طلب year رجع بعد 4 ثواني | بيبص على الـ ignore بتاعه هو بيلاقيه بقى true، فبيسكت ومبيقربش من الشاشة. | (الشاشة في أمان وعارضة بيانات الأسبوع زي ما هي) |
ولو بتفضل تكتب بـ async / await بدل .then()، نفس الفكرة بالظبط. وهنا كمان فرصة نوضّح تفصيلة مهمة: اليوزر بيشوف إيه لما الـ period تتغير؟
قبل ما الطلب الجديد يبدأ، بنحتاج نصفّر أي خطأ قديم (setError(null)) ونفعّل حالة التحميل (setIsLoading(true)). أما بخصوص report نفسه، فعندك اختيارين حسب الـ UX اللي عاوزه:
- يا إما تعمل
setReport(null)في بداية الـ Effect عشان تخفي التقرير القديم خالص وتعرض شاشة التحميل. - يا إما تسيب التقرير القديم معروض مع مؤشر تحميل خفيف (
isLoading) لحد ما الجديد يجهز.
الأهم إنك لما تقفل التحميل في الـ finally، تحطه هو كمان جوه شرط if (!ignore) عشان الطلب القديم الملغي ميقفلش اللودينج والطلب الجديد لسه شغال:
useEffect(() => {
let ignore = false;
async function loadReport() {
setIsLoading(true);
setError(null);
// اختياري: لو عايز تمسح التقرير القديم فوراً وتظهر شاشة Loading كاملة
// setReport(null);
try {
const res = await fetch(`/api/reports?period=${period}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!ignore) {
setReport(data);
}
} catch (err) {
// حتى الـ Error لازم نحطله نفس الشرط
// عشان لو الطلب القديم فشل، ميطلعلناش رسالة خطأ واحنا واقفين على التاب الجديد
if (!ignore) {
setError(err.message);
}
} finally {
// مننساش الشرط هنا برضه عشان الطلب القديم ميوقفش اللودينج بتاع الطلب الجديد
if (!ignore) {
setIsLoading(false);
}
}
}
loadReport();
return () => {
ignore = true;
};
}, [period]);
الحل التاني: نوقف الطلب من ناحية المتصفح بـ AbortController
متغير ignore حمى الشاشة ومخلّاش الداتا تغلط، بس لسه فيه نقطة مهمة: الطلب القديم بتاع السنة فضل مفتوح عند المتصفح الـ 4 ثواني كلهم، واستلم الرد وحوله لـ JSON (res.json()) على الفاضي، وفي الآخر لما خلص تجاهلناه.
طب ليه منقفلش الطلب ده من ناحية المتصفح أول ما اليوزر يغير رأيه؟ هنا ييجي دور الـ AbortController (مع العلم إن abort() بيقطع الاتصال ويوقف استلام البيانات من ناحية العميل/المتصفح، لكن السيرفر نفسه ممكن يكون كمّل تنفيذ الكويري لو مفيش معالجة لإغلاق الاتصال هناك):
useEffect(() => {
let ignore = false;
const controller = new AbortController();
async function loadReport() {
setIsLoading(true);
setError(null);
try {
// بنربط الـ fetch بالـ controller عن طريق الـ signal
const res = await fetch(`/api/reports?period=${period}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!ignore) {
setReport(data);
}
} catch (err) {
// خلي بالك: لما بنعمل abort()، الـ fetch بترمي خطأ اسمه AbortError
// ده مش عطل في السيرفر، ده إحنا اللي قاصدين نوقف الطلب، فبنعمل return ونتجاهله
if (err.name === 'AbortError') return;
if (!ignore) {
setError(err.message);
}
} finally {
if (!ignore) {
setIsLoading(false);
}
}
}
loadReport();
return () => {
ignore = true;
controller.abort(); // اقطع الطلب فوراً من ناحية المتصفح
};
}, [period]);
طب طالما عملنا controller.abort()، ليه سايبين ignore معاه؟
في أغلب الحالات مع fetch، الـ abort() لوحده بيوقف الطلب ويرمي AbortError فمش بنوصل لسطر setReport. بس وجود ignore معاه مهم جداً لسبب تقني دقيق:
- لو الرد اتقرأ قبل لحظة الإلغاء: افرض إن الرد وصل وخطوة
await res.json()خلصت فعلاً في نفس اللحظة قبل ماcontroller.abort()تتنادى؛ ساعتها مفيشAbortErrorهيترمي لأن القراءة من الشبكة خلصت خلاص! هناif (!ignore)هي اللي بتقف وتمنعsetReportيتنفذ. - لو في يوم غيرت الـ
fetchبدالة أو مكتبة تانية مبتدعمش الـsignal، الكود بتاعك يفضل محمي وميعرضش بيانات غلط.
جربها بنفسك في المتصفحافتح الـ Network Tab في الـ DevTools وقلّب بين التابات بسرعة وأنت حاطط
controller.abort(). هتلاقي الطلبات القديمة اتقطعت في لحظتها واتكتب قدامها(canceled).
الحل التالت: لو شغال بره useEffect أو بمكتبة مفيهاش signal
ساعات الطلب مبيكونش جوه useEffect أصلاً — مثلاً دالة بتشتغل جوه onChange بتاع إنبوت بحث، أو بتنده SDK خارجي مبيقبلش AbortController. هنا مفيش دالة Cleanup ترجعها.
الحل هنا هو فكرة “أرقام الدور” باستخدام useRef: بنعمل عداد بيزيد 1 مع كل طلب جديد. ولما الطلب يرجع، بنشوف: هل رقمي هو آخر رقم طلع؟ لو آه اعرض الداتا، لو فيه رقم أحدث مني طلع بعدي، اسكت.
import { useRef, useState } from 'react';
function ProductSearch() {
const [results, setResults] = useState([]);
const [error, setError] = useState(null);
const latestRequestId = useRef(0);
async function handleSearch(e) {
const query = e.target.value;
// نزود العداد وندّي الطلب الحالي رقمه
const myRequestId = ++latestRequestId.current;
setError(null);
try {
const data = await searchProductsSDK(query);
// لو اليوزر كتب حرف تاني والطلب ده شغال، latestRequestId.current هيكون زاد
// فالشرط ده مش هيتحقق للطلب القديم
if (myRequestId === latestRequestId.current) {
setResults(data);
}
} catch (err) {
if (myRequestId === latestRequestId.current) {
setError(err.message);
}
}
}
return (
<div>
<input type="search" onChange={handleSearch} placeholder="ابحث عن منتج..." />
{error && <p>{error}</p>}
</div>
);
}
3 مسكنات مش بتحل المشكلة (متعتمدش عليهم لوحدهم)
فيه 3 حاجات منتشرة جداً، ناس كتير بتعملها وتفتكر إنها كده قفلت باب الـ Race Condition، بس هي في الحقيقة مجرد مسكنات:
1. الـ Debounce لوحده
إنك تحط Debounce (مثلاً تستنى 300ms بعد ما اليوزر يوقف كتابة قبل ما تبعت الطلب) دي حاجة ممتازة عشان متصدعش السيرفر بطلب مع كل حرف. بس هل ده يمنع السباق؟ لأ. افرض اليوزر كتب "لابتوب" ووقف نص ثانية (الطلب الأول طلع، وعشان الكلمة عامة خدت في السيرفر 3 ثواني). بعدها كمّل كتابة "لابتوب ديل جيمينج" ووقف نص ثانية (الطلب التاني طلع، وعشان محدد خلص في نص ثانية). الطلب الأول هيرجع متأخر ويمسح نتايج التاني. الـ Debounce مساعد ممتاز، بس مش بديل عن ignore أو AbortController.
2. شرط if (loading) return
إنك تقفل الزرار طول ما فيه loading ده منطقي في زرار حفظ فورم أو دفع، بس في التابات والفلاتر والبحث مينفعش تجبر اليوزر يقعد يتفرج ع الشاشة لحد ما الطلب القديم يخلص عشان يقدر يدوس على تاب تاني. وحتى لو عملت كده، أي طلب قديم كان طلع في الهوا ملوش دعوة بمتغير loading وهيكتب في الـ State أول ما يوصل.
3. إنك تطفي الـ StrictMode
في React 18 و 19، وأنت شغال على جهازك في الـ Development، الـ StrictMode بيشغّل كل useEffect مرتين ورا بعض أول ما الكومبوننت يفتح عن قصد: Setup ───► Cleanup ───► Setup
React بتعمل الحركة دي عشان تختبر الكود بتاعك وتكشفلك بدري لو أنت ناسي تكتب Cleanup. لما تشوف الطلب رايح مرتين في الـ Network Tab وتضايق فتروح ماسح <StrictMode>، أنت كده طفيت جهاز الإنذار وسبت المشكلة زي ما هي. الحل الصح إنك تكتب الـ Cleanup: مع abort() الطلب الأول بيتلغي على مستوى المتصفح، ومع ignore لوحدها الطلبين بيروحوا للسيرفر فعلاً بس نتيجة الأول بتتجاهل ومبتتكتبش في الـ State. وفي الـ Production كده كده الـ Effect بيشتغل مرة واحدة بس.
الفرق بين الـ Race Condition والـ Stale Closure
كتير من المبرمجين بيخلطوا بين المصطلحين دول لأن الاتنين بيظهروا في الكود غير المتزامن (Async) وبينتج عنهم بيانات قديمة على الشاشة، بس السبب والعلاج مختلفين:
- الـ Race Condition (سباق الشبكة): المشكلة هنا في ترتيب وصول الردود من السيرفر. عندك طلبين طلعوا في أوقات مختلفة، والطلب القديم البطيء خلّص بعد الطلب الجديد السريع وكتب فوقه. وعلاجها بيكون بتجاهل أو إلغاء الطلب القديم (
ignoreأوAbortController). - الـ Stale Closure (الإغلاق على قيمة قديمة): المشكلة هنا في قراءة متغير قديم من ذاكرة الريندر السابق، حتى لو عندك طلب واحد بس ومفيش أي سباق! الكود اللي جوه الـ
asyncأو الـsetTimeoutبيكون قافل (Closed over) على قيمة الـ State القديمة اللي كانت موجودة وقت ما الدالة بدأت.
شوف المثال ده على الـ Stale Closure:
// غلط (Stale Closure):
// لو قيمة count اتغيرت أثناء ما الطلب شغال، السطر ده هيقرأ قيمة count القديمة
// اللي كانت موجودة لحظة ما الدالة اشتغلت، وهيمسح أي زيادة حصلت في النص
setCount(count + 1);
// صح (Functional Update):
// دالة التحديث بتخلي React تمررلك أحدث قيمة وصلتلها الـ State لحظة التنفيذ الفعلي
setCount((prevCount) => prevCount + 1);
لما المشروع يكبر: ريّح دماغك بـ TanStack Query أو SWR
لو عندك شاشة ولا اتنين في مشروع صغير، كتابة ignore و AbortController بإيدك سهلة ومش هتاخد دقيقة. لكن لو شغال في مشروع كبير وفيه عشرات الشاشات والفلاتر، مش منطقي تكرر نفس الـ Boilerplate في كل كومبوننت.
هنا مكتبات زي TanStack Query (React Query) أو SWR بتشيل عنك الليلة دي كلها ورا الكواليس عن طريق الـ queryKey:
import { useQuery } from '@tanstack/react-query';
function SalesReport({ period }) {
const { data: report, isPending, isError, error } = useQuery({
// المفتاح ده هو عنوان الطلب
// أول ما period تتغير، المكتبة بتعرف إن الشاشة دلوقتي تبع المفتاح الجديد
queryKey: ['sales-report', period],
// وكمان بتديك signal جاهز تمرره للـ fetch عشان تلغي الطلب القديم أوتوماتيك
queryFn: async ({ signal }) => {
const res = await fetch(`/api/reports?period=${period}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return <ReportView data={report} />;
}
لما اليوزر يقلب من year لـ week، المفتاح بيتغير. حتى لو طلب year القديم رجع متأخر، المكتبة بتحطه في الكاش بتاع year على جنب ومش بتخليه يلمس الشاشة اللي عارضة week.
ونفس الفكرة بالظبط بـ SWR:
import useSWR from 'swr';
const fetcher = async (url) => {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
};
function SalesReport({ period }) {
const { data: report, error, isLoading } = useSWR(
`/api/reports?period=${period}`, // الـ URL نفسه هو الـ Key هنا
fetcher
);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ReportView data={report} />;
}
غلطة مشهورة في React Queryعشان المكتبة تحميك من السباق، لازم أي متغير بتبعته في الطلب يكون موجود جوه الـ
queryKey. يعني لو كتبتqueryKey: ['sales-report']ونسيت تحطperiodجواها، المكتبة هتفتكر إن الأسبوع والسنة هما نفس الطلب والداتا هتتلخبط في بعض.
طيب و React 19؟ موقعها إيه من الكلام ده؟
React 19 قدّمت طرق جديدة للتعامل مع الداتا غير الـ useEffect، وده غيّر شكل الكود حسب الطريقة اللي شغال بيها:
1. قراءة الـ Promise بـ use() مع <Suspense>
الطريقة القديمة كانت: اعمل Render الأول ◄ شغّل useEffect ◄ هات الداتا ◄ اعمل setState. في React 19، بقى ينفع تقرأ الـ Promise مباشرة جوه الـ Render عن طريق use(promise)، و <Suspense> يتكفل بعرض الـ Loading لحد ما الداتا تخلص، من غير ولا useEffect ولا useState.
بس فيه فخ مهم جداً هنا: مينفعش تعمل fetch جوه الـ Render مباشرة من غير كاش! لأن مع كل Render هيتعمل fetch جديد بـ Promise جديدة، فالكومبوننت هيفضل معلق في Infinite Loop مبيخرجش منها:
// غلط: بيعمل Promise جديدة في كل Render وهيدخلك في Suspense Loop ملهاش آخر
function SalesReport({ period }) {
const report = use(fetch(`/api/reports?period=${period}`).then((r) => r.json()));
return <ReportView data={report} />;
}
الصح إن الـ Promise تكون جاية من Server Component، أو من مكتبة بتدير الكاش، مع استخدام useTransition وأنت بتغير الفلتر عشان الداتا القديمة تفضل قدام عين اليوزر لحد ما الجديدة تجهز بدل ما الشاشة كلها تختفي.
لتوضيح الفكرة برمجياً، تعال نشوف إزاي use() بتشتغل لما الـ Promise تكون محفوظة لكل مفتاح:
import { use, Suspense, useTransition, useState } from 'react';
// تنبيه: هذا الـ Map للتوضيح التعليمي فقط وليس للاستخدام في الـ Production (راجع التحذير بالأسفل)
const reportCache = new Map();
function getReportPromise(period) {
if (!reportCache.has(period)) {
const promise = fetch(`/api/reports?period=${period}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});
reportCache.set(period, promise);
}
return reportCache.get(period);
}
function SalesReport({ period }) {
// هنا الكومبوننت بيقرأ الـ Promise بتاعة الـ period الحالية بس، فمفيش سباق
const report = use(getReportPromise(period));
return <ReportView data={report} />;
}
function Dashboard() {
const [period, setPeriod] = useState('week');
const [isPending, startTransition] = useTransition();
function handlePeriodChange(newPeriod) {
// startTransition بتخلي التغيير ناعم ومتخفيش الشاشة الحالية فجأة
startTransition(() => {
setPeriod(newPeriod);
});
}
return (
<div>
<button onClick={() => handlePeriodChange('week')}>آخر 7 أيام</button>
<button onClick={() => handlePeriodChange('year')}>السنة كاملة</button>
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<Suspense fallback={<p>Loading...</p>}>
<SalesReport period={period} />
</Suspense>
</div>
</div>
);
}
تحذير مهم: ليه متستخدمشnew Map()ككاش في مشاريع حقيقية؟المثال اللي فوق بيشرح الميكانيزم بس، لكن في تطبيق حقيقي على الـ Production إياك تعمل
Mapعامة بالشكل ده للأسباب دي:
- تسريب الذاكرة (Memory Leak): البيانات اللي بتدخل الـ
Mapمبتتمسحش أبداً مهما المستخدم فتح صفحات، فاستهلاك الرامات هيفضل يزيد.- تخزين الأخطاء للأبد: لو الطلب فشل لثانية واحدة والـ Promise اترفضت (Rejected)، الـ Promise المرفوضة هتفضل متخزنة جوه الـ
Mapومش هتعرف تعمل Retry بسهولة.- مشترك بين الطلبات في الـ SSR: لو الـ
Mapاللي متعرفة على مستوى الموديول (Module-level) اتنفذت على السيرفر، هتتشارك بين كل الطلبات (Requests)؛ وفي حالة إن الداتا دي مرتبطة بمستخدم معين (مش عامة زي مثالنا هنا)، ده هيسبب تسريب بيانات بين المستخدمين.البديل الصح في الشغل الحقيقي: مرر الـ Promise كـ Prop من Server Component لـ Client Component، أو استخدم مكتبة جاهزة بتدعم Suspense زي
useSuspenseQueryفي TanStack Query أو SWR.
2. الـ Server Components (RSC)
لو شغال بـ Next.js مثلاً، تقدر تجيب داتا الصفحة مباشرة على السيرفر بـ async / await:
// Server Component
async function SalesReportPage({ searchParams }) {
const { period = 'week' } = await searchParams;
const res = await fetch(`https://api.example.com/reports?period=${period}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const report = await res.json();
return <ReportView data={report} />;
}
هنا مفيش useEffect على الكلاينت أصلاً للتحميل الأولي. بس ده مبيمنعش إن لو عندك Client Component جوه الصفحة بيعمل بحث فوري أو فلترة سريعة بـ useEffect، هتفضل محتاج ignore أو AbortController أو useQuery.
3. الـ Actions (useActionState) — للتعديل مش القراءة
React 19 فرّقت بوضوح بين قراءة الداتا (GET) وتعديل الداتا (POST / PUT / DELETE). الـ Actions معمولة عشان لما اليوزر يبعت فورم أو يعدل بيانات، و React بتدير حالة الـ isPending والـ Errors أوتوماتيك:
import { useActionState } from 'react';
function UpdateSettingsForm() {
const [error, submitAction, isPending] = useActionState(
async (_prevState, formData) => {
const companyName = formData.get('companyName');
const err = await updateCompanyName(companyName);
return err; // لو تمام بترجع null، لو فيه مشكلة بترجع نص الخطأ
},
null
);
return (
<form action={submitAction}>
<input name="companyName" />
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
{error && <p>{error}</p>}
</form>
);
}
من الآخر.. أستخدم إيه إمتى؟
عشان تلم الدنيا في دماغك، ده ملخص سريع لكل حالة:
| الحالة اللي قدامك | الحل اللي تستخدمه | ليه؟ |
|---|---|---|
fetch عادي جوه useEffect | AbortController + ignore | بيقطع الطلب الملغي في الشبكة ويوفر موارد + بيحمي الشاشة من أي داتا قديمة. |
مكتبة جوه useEffect مبتدعمش signal | متغير ignore في الـ Cleanup | سطرين كود بيضمنوا إن القديم عمره ما هيكتب فوق الجديد. |
طلب Async جوه onChange أو onClick | useRef (عداد أرقام الطلبات) | لأن مفيش Cleanup Function بره الـ useEffect. |
| مشروع كبير فيه فلاتر وطلبات كتير | TanStack Query أو SWR | بيشيلوا عنك وجع الدماغ بتاع الـ Race Conditions والكاش والـ Loading. |
| شغال بـ React 19 أو Next.js | Server Components أو use() مع كاش و <Suspense> | بتقرأ الداتا مع الـ Render مباشرة من غير ما تحتاج useEffect ولا setState. |
الخلاصة
الـ Race Condition بيحصل عشان إحنا ساعات بننسى إن الشبكة مالهاش أمان، وإن الطلب اللي طلع الأول مش شرط يرجع الأول.
قبل ما تكتب أي setState بعد await أو جوه .then()، اسأل نفسك دايماً السؤال البسيط ده:
“لما السطر ده يشتغل كمان شوية، هل اليوزر هيكون لسه واقف على نفس الشاشة ومستني النتيجة دي؟”
لو فيه احتمال 1% إنه يكون غيّر رأيه وراح لحتة تانية — حط الـ ignore والـ AbortController بتوعك وريح دماغك.

