SYS.ONLINE
[ ASK_ME ↗ ]
INDEX / DOC // SPEC
3149 words
•
16 minutes

Race Conditions في React — لما الطلب القديم يسبق الجديد ويكتب فوقه

Race Conditions في React — لما الطلب القديم يسبق الجديد ويكتب فوقه

Race Conditions في React — لما الطلب القديم يسبق الجديد ويكتب فوقه#

غالباً لو اشتغلت بـ React وعملت fetch جوه useEffect، هيكون عدّى عليك المشهد ده (أو هيقابلك قريب): اليوزر بيقلّب بين التابات بسرعة، أو بيغيّر فلتر في لوحة التحكم، أو بيكتب في السيرش، وفجأة تلاقي الشاشة عارضة داتا غلط خالص — مش عشان الباك إند فيه مشكلة، لأ، عشان فيه طلب قديم اتأخر في الطريق ولما وصل كتب فوق الطلب الجديد.

الـ Bug ده صعب في اكتشافه لأنه صامت على الآخر:

  • مفيش أي Error في الكونسول.
  • الـ Network Tab كلها مخضّرة وبترجع 200 OK.
  • الكود شكله نضيف ومفهوش غلطة.

ومع ذلك، تلاقي مهندس الـ QA (أو الـ Tester) أو العميل بيكلمك يقولك: “أنا واقف على فلتر الأسبوع، بس الأرقام اللي قدامي دي بتاعة السنة اللي فاتت”.

المشكلة دي اسمها Race Condition (حالة سباق). تعال نفهمها من جذورها وبأمثلة حقيقية من الشغل، ونشوف إزاي نقفل الباب ده خالص.


القصة بتبدأ إزاي؟#

تخيل إنك شغال على Dashboard لمبيعات شركة، وفيها زرارين اليوزر بيبدّل بينهم:

  • تقرير السنة (year): ده بيجيب داتا ضخمة وحسابات كتير من الداتابيز، فالسيرفر بياخد حوالي 4 ثواني عشان يرد.
  • تقرير آخر 7 أيام (week): ده خفيف وسريع، السيرفر بيرد في نص ثانية بس.

شوف بقى اللي اليوزر بيعمله في الطبيعي:

  1. فتح الصفحة وداس على “السنة” بالغلط، فطلع أول طلب للسيرفر (Request 1).
  2. بعدها بثانية واحدة، افتكر إنه عايز “آخر 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. اليوزر غيّر لـ weekReact شغلت الـ 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 معاه مهم جداً لسبب تقني دقيق:

  1. لو الرد اتقرأ قبل لحظة الإلغاء: افرض إن الرد وصل وخطوة await res.json() خلصت فعلاً في نفس اللحظة قبل ما controller.abort() تتنادى؛ ساعتها مفيش AbortError هيترمي لأن القراءة من الشبكة خلصت خلاص! هنا if (!ignore) هي اللي بتقف وتمنع setReport يتنفذ.
  2. لو في يوم غيرت الـ 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 عامة بالشكل ده للأسباب دي:

  1. تسريب الذاكرة (Memory Leak): البيانات اللي بتدخل الـ Map مبتتمسحش أبداً مهما المستخدم فتح صفحات، فاستهلاك الرامات هيفضل يزيد.
  2. تخزين الأخطاء للأبد: لو الطلب فشل لثانية واحدة والـ Promise اترفضت (Rejected)، الـ Promise المرفوضة هتفضل متخزنة جوه الـ Map ومش هتعرف تعمل Retry بسهولة.
  3. مشترك بين الطلبات في الـ 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 عادي جوه useEffectAbortController + ignoreبيقطع الطلب الملغي في الشبكة ويوفر موارد + بيحمي الشاشة من أي داتا قديمة.
مكتبة جوه useEffect مبتدعمش signalمتغير ignore في الـ Cleanupسطرين كود بيضمنوا إن القديم عمره ما هيكتب فوق الجديد.
طلب Async جوه onChange أو onClickuseRef (عداد أرقام الطلبات)لأن مفيش Cleanup Function بره الـ useEffect.
مشروع كبير فيه فلاتر وطلبات كتيرTanStack Query أو SWRبيشيلوا عنك وجع الدماغ بتاع الـ Race Conditions والكاش والـ Loading.
شغال بـ React 19 أو Next.jsServer Components أو use() مع كاش و <Suspense>بتقرأ الداتا مع الـ Render مباشرة من غير ما تحتاج useEffect ولا setState.

الخلاصة#

الـ Race Condition بيحصل عشان إحنا ساعات بننسى إن الشبكة مالهاش أمان، وإن الطلب اللي طلع الأول مش شرط يرجع الأول.

قبل ما تكتب أي setState بعد await أو جوه .then()، اسأل نفسك دايماً السؤال البسيط ده:

“لما السطر ده يشتغل كمان شوية، هل اليوزر هيكون لسه واقف على نفس الشاشة ومستني النتيجة دي؟”

لو فيه احتمال 1% إنه يكون غيّر رأيه وراح لحتة تانية — حط الـ ignore والـ AbortController بتوعك وريح دماغك.

TERMINAL.COLOPHON // CORE v2.5
LAST.DISPATCH: 2026.09.19
guest@ag-coding:~ (interactive-zsh)
LIVE CLI
RUN //
01 // ISSUES ●
53
Published Articles
02 // CORPUS ≈ 6.7H
80.8K
Total Words Written
03 // SECTORS #
2/39
Categories & Tags
04 // UPTIME 99.9%
ONLINE
Static Edge Node
QUANTUM TELEPORTER // ROULETTE 1-CLICK JUMP
TARGET DESTINATION:
TELEPORT NOW ↗
© 2026 Abdoelaziz Gamal // ALL RIGHTS RESERVED.
30.0444° N, 31.2357° E ■ DESIGNED & ENGINEERED IN CAIRO
OPERATOR // RADAR.HUD
SCROLL.PROGRESS
00% LIVE
Abdoelaziz Gamal
LIVE
// CHIEF ARCHITECT
Abdoelaziz Gamal
REACT.JS NEXT.JS SYS.ARCH
root@ag-coding:~# cat dossier.log ● UTF-8

Full-stack engineer from Mansoura. 5+ years shipping TypeScript / Next.js products — dashboards, multi-tenant apps, CRM, LMS, e-commerce. Behind AG Coding.