أكثر من ٨٠٪ من مشاريع React في الإنتاج تعاني من مشاكل أداء ومعمارية يمكن تجنبها بسهولة. هذا المقال يكشف الأخطاء الشائعة في كتابة React، ويشرح بالتفصيل ماذا يحدث خلف الكواليس وكيفية إصلاحها بأكواد حقيقية.
في أحد المراجعات الأسبوعية لمشروع كبير لشركة ناشئة في دبي، فتحت لوحة التحكم ووجدت أن مكون واحد فقط من مكونات الـ Dashboard يستهلك ٣٢٠ ميغابايت من الذاكرة. المثير للدهشة أن هذا المكون لم يكن يعرض بيانات ضخمة أو رسوميات معقدة، بل مجرد جدول بسيط يحتوي على ٥٠ صفاً. المشكلة؟ المطور كتب useEffect بدون قائمة اعتماد، مما تسبب في إعادة إنشاء ٥٠٠٠ كائن في الذاكرة في كل رندر. هذا ليس خطأ مبتدئ، بل نمط شائع حتى بين المطورين الذين لديهم سنوات من الخبرة في React.
الحقيقة المؤلمة هي أن معظم المطورين يكتبون React بطريقة تعمل، لكنها ليست الطريقة الصحيحة. الفرق بين الكود الذي يعمل والكود الذي يعمل بكفاءة هو ما يفصل بين التطبيقات البطيئة التي تعاني من الـ Memory Leaks والتطبيقات السلسة التي تتعامل مع ملايين المستخدمين. في هذا المقال، سنفكك الأخطاء الشائعة في كتابة React، ونشرح لماذا تحدث، وكيفية إصلاحها بأكواد حقيقية يمكن تطبيقها فوراً في مشاريعك الحالية.
useEffect هو أحد أكثر الـ Hooks استخداماً في React، وهو أيضاً أكثرها سوء استخدام. المشكلة الأساسية تكمن في أن المطورين يعاملون useEffect كبديل لكل شيء: بدء الـ API Calls، تحديث الـ State، التعامل مع الـ Events، وحتى إدارة الـ Subscriptions. لكن الحقيقة هي أن useEffect ليس مصمماً لهذا الغرض. عندما تضع كل هذه المنطقيات في useEffect واحد، فإنك تخلق ما يسمى بـ "Effect Storm"، حيث تتداخل الـ Effects مع بعضها البعض وتسبب إعادة رندر غير ضرورية.
لنأخذ مثالاً واقعياً من مشروع حقيقي. في تطبيق لإدارة المهام، كان هناك مكون يعرض قائمة المهام ويتيح للمستخدم فلترتها حسب الحالة. المطور كتب useEffect بهذا الشكل:
// ❌ خطأ شائع: useEffect يقوم بكل شيء
useEffect(() => {
// جلب البيانات من API
fetchTasks().then(tasks => setTasks(tasks));
// تحديث فلتر المهام
const filtered = tasks.filter(task => task.status === filter);
setFilteredTasks(filtered);
// إضافة مستمع للـ Event
window.addEventListener('resize', handleResize);
// تنظيف المستمع
return () => window.removeEventListener('resize', handleResize);
}, [filter]); // قائمة الاعتماد تحتوي على filter فقطهذا الكود يبدو بريئاً، لكنه يسبب مشاكل كبيرة. أولاً، في كل مرة يتغير filter، يتم إعادة جلب البيانات من API وإعادة حساب الفلتر وإعادة إضافة المستمع للـ Event. ثانياً، إذا تغيرت tasks من مصدر آخر (مثل WebSocket)، فإن الفلتر لن يتم تحديثه لأن useEffect يعتمد على filter فقط. ثالثاً، إذا كان هناك خطأ في API، فإن الكود سيحاول دائماً إعادة جلب البيانات دون أي منطق للـ Retry أو الـ Error Handling.
الحل الصحيح هو تقسيم هذا useEffect إلى عدة useEffect أصغر، كل واحد مسؤول عن مهمة واحدة فقط. بالإضافة إلى ذلك، يجب استخدام مكتبات متخصصة مثل react-query أو SWR لإدارة الـ API Calls بدلاً من كتابتها يدوياً في useEffect. إليك كيف يمكن إصلاح هذا الكود:
// ✅ الحل الصحيح: تقسيم الـ Effects واستخدام react-query
const { data: tasks } = useQuery('tasks', fetchTasks);
useEffect(() => {
if (tasks) {
const filtered = tasks.filter(task => task.status === filter);
setFilteredTasks(filtered);
}
}, [tasks, filter]); // قائمة الاعتماد تحتوي على tasks و filter
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // قائمة الاعتماد فارغة لأننا نريد تشغيل هذا مرة واحدة فقطواحدة من أكبر الأخطاء التي يقع فيها المطورون عند كتابة React هي تجاهل كيفية عمل الـ Event Loop في JavaScript. عندما تكتب كوداً متزامناً داخل مكون React، فإنك توقف الـ Event Loop بالكامل، مما يعني أن الواجهة تصبح غير مستجيبة حتى ينتهي الكود من التنفيذ. هذا ليس مجرد مشكلة أداء، بل مشكلة تجربة مستخدم حقيقية.
لنأخذ مثالاً بسيطاً: حساب مجموع الأعداد من ١ إلى مليون. إذا كتبت هذا الكود داخل مكون React بدون استخدام async/await أو Web Workers، فإن الواجهة ستتجمد تماماً حتى ينتهي الحساب. إليك كيف يبدو هذا الخطأ:
// ❌ خطأ شائع: Blocking Call داخل المكون
function SumComponent() {
const [sum, setSum] = useState(0);
const calculateSum = () => {
let result = 0;
for (let i = 1; i <= 1000000; i++) {
result += i;
}
setSum(result);
};
return (
<div>
<button {calculateSum}>Calculate Sum</button>
<p>Sum: {sum}</p>
</div>
);
}عندما ينقر المستخدم على الزر، فإن الواجهة تتجمد تماماً لمدة قد تصل إلى عدة ثوانٍ. هذا لأن الـ Event Loop متوقف عن معالجة أي أحداث أخرى حتى ينتهي حساب المجموع. الحل الصحيح هو استخدام Web Workers أو تقسيم الحساب إلى أجزاء صغيرة باستخدام setTimeout أو requestIdleCallback. إليك كيف يمكن إصلاح هذا الكود باستخدام Web Workers:
// ✅ الحل الصحيح: استخدام Web Worker
// worker.js
self. function(e) {
let result = 0;
for (let i = 1; i <= 1000000; i++) {
result += i;
}
self.postMessage(result);
};
// SumComponent.js
function SumComponent() {
const [sum, setSum] = useState(0);
const calculateSum = () => {
const worker = new Worker('worker.js');
worker.onmessage = (e) => {
setSum(e.data);
};
};
return (
<div>
<button onClick={calculateSum}>Calculate Sum</button>
<p>Sum: {sum}</p>
</div>
);
}هذا الحل يضمن أن الحساب الثقيل يتم في خلفية الـ Thread، مما يترك الـ Event Loop حراً لمعالجة أحداث المستخدم الأخرى. النتيجة؟ واجهة مستجيبة حتى أثناء العمليات الثقيلة.
واحدة من أكثر المشاكل شيوعاً في تطبيقات React هي إعادة إنشاء الـ Callbacks في كل رندر. عندما تكتب دالة داخل مكون React بدون استخدام useCallback، فإن React يعيد إنشاء هذه الدالة في كل مرة يتم فيها إعادة رندر المكون. هذا ليس مجرد مشكلة أداء، بل يمكن أن يسبب مشاكل في الـ Child Components التي تعتمد على هذه الدوال.
لنأخذ مثالاً واقعياً من تطبيق للتسوق الإلكتروني. كان هناك مكون يعرض قائمة المنتجات، وكل منتج يحتوي على زر "أضف إلى السلة". المطور كتب الكود بهذا الشكل:
// ❌ خطأ شائع: إعادة إنشاء الدوال في كل رندر
function ProductList({ products }) {
const [cart, setCart] = useState([]);
const addToCart = (product) => {
setCart([...cart, product]);
};
return (
<div>
{products.map(product => (
<Product
key={product.id}
product={product}
{addToCart} // هذه الدالة يتم إعادة إنشائها في كل رندر
/>
))}
</div>
);
}المشكلة هنا هي أن الدالة addToCart يتم إعادة إنشائها في كل مرة يتم فيها إعادة رندر ProductList. هذا يعني أن المكون Product يستقبل props جديدة في كل رندر، مما يسبب إعادة رندر غير ضرورية له أيضاً. بالإضافة إلى ذلك، إذا كان هناك useEffect داخل Product يعتمد على onAddToCart، فإن هذا useEffect سيتم تشغيله في كل رندر أيضاً.
الحل الصحيح هو استخدام useCallback لتثبيت الدالة ومنع إعادة إنشائها في كل رندر. إليك كيف يمكن إصلاح هذا الكود:
// ✅ الحل الصحيح: استخدام useCallback
function ProductList({ products }) {
const [cart, setCart] = useState([]);
const addToCart = useCallback((product) => {
setCart(prevCart => [...prevCart, product]);
}, []); // قائمة الاعتماد فارغة لأننا لا نعتمد على أي متغير خارجي
return (
<div>
{products.map(product => (
<Product
key={product.id}
product={product}
{addToCart}
/>
))}
</div>
);
}لاحظ أننا استخدمنا أيضاً الدالة المحدثة لـ setCart (prevCart => [...prevCart, product]) بدلاً من استخدام cart مباشرة في قائمة الاعتماد. هذا يضمن أننا نستخدم أحدث نسخة من cart دون الحاجة إلى إضافتها لقائمة الاعتماد، مما يمنع إعادة إنشاء useCallback في كل مرة يتغير cart.
واحدة من أكثر المشاكل خطورة في تطبيقات React هي الـ Memory Leaks التي تحدث في الـ Effects. عندما تقوم بإضافة مستمع للـ Event أو اشتراك في WebSocket داخل useEffect دون تنظيفه بشكل صحيح، فإنك تخلق مرجعاً لا يتم تحريره أبداً، مما يؤدي إلى تسرب الذاكرة. هذا النوع من الأخطاء لا يظهر عادة في التطوير، بل يظهر في الإنتاج عندما يستخدم التطبيق لفترة طويلة.
لنأخذ مثالاً من تطبيق للدردشة في الوقت الحقيقي. المطور كتب useEffect لإضافة مستمع للـ Event عند دخول المستخدم إلى الدردشة، لكنه نسي تنظيف المستمع عند مغادرة المستخدم للدردشة. إليك كيف يبدو هذا الخطأ:
// ❌ خطأ شائع: عدم تنظيف الـ Event Listeners
useEffect(() => {
const handleMessage = (message) => {
setMessages(prev => [...prev, message]);
};
socket.on('message', handleMessage);
// ❌ لم يتم تنظيف المستمع
}, []);المشكلة هنا هي أنه عندما يغادر المستخدم صفحة الدردشة، فإن المستمع للـ Event يبقى نشطاً في الذاكرة. إذا عاد المستخدم إلى صفحة الدردشة مرة أخرى، فسيتم إضافة مستمع جديد، مما يعني أن المستمع القديم لا يزال نشطاً. بمرور الوقت، يتراكم عدد كبير من المستمعين في الذاكرة، مما يؤدي إلى تسرب الذاكرة وتباطؤ التطبيق.
الحل الصحيح هو دائماً تنظيف الـ Effects عند إلغاء تحميل المكون. إليك كيف يمكن إصلاح هذا الكود:
// ✅ الحل الصحيح: تنظيف الـ Event Listeners
useEffect(() => {
const handleMessage = (message) => {
setMessages(prev => [...prev, message]);
};
socket.on('message', handleMessage);
return () => {
socket.off('message', handleMessage); // تنظيف المستمع عند إلغاء تحميل المكون
};
}, []);هذا يضمن أن المستمع يتم إزالته من الذاكرة عند إلغاء تحميل المكون، مما يمنع تسرب الذاكرة. قاعدة بسيطة: إذا أضفت شيئاً في useEffect، فتأكد من تنظيفه في دالة الإرجاع.
الـ Context في React هو أداة قوية لمشاركة البيانات بين المكونات، لكنه ليس قاعدة بيانات. واحدة من أكبر الأخطاء التي يقع فيها المطورون هي استخدام الـ Context لتخزين كل شيء، بما في ذلك البيانات التي يتم تحديثها بشكل متكرر. عندما تضع بيانات متغيرة في الـ Context، فإنك تسبب إعادة رندر لكل المكونات التي تستهلك هذا الـ Context، حتى لو لم تكن بحاجة إلى هذه البيانات.
لنأخذ مثالاً من تطبيق لإدارة المهام. كان هناك Context لتخزين قائمة المهام، ويتم تحديث هذه القائمة في كل مرة يضيف المستخدم مهمة جديدة أو يعدل مهمة موجودة. المشكلة هي أن كل المكونات التي تستهلك هذا الـ Context يتم إعادة رندرها في كل مرة تتغير القائمة، حتى لو كانت هذه المكونات لا تعرض قائمة المهام مباشرة. إليك كيف يبدو هذا الخطأ:
// ❌ خطأ شائع: استخدام الـ Context لتخزين بيانات متغيرة بشكل متكرر
const TasksC createContext();
function TasksProvider({ children }) {
const [tasks, setTasks] = useState([]);
const addTask = (task) => {
setTasks(prev => [...prev, task]); // هذا يسبب إعادة رندر لكل المكونات التي تستهلك الـ Context
};
return (
<TasksContext.Provider value={{ tasks, addTask }}>
{children}
</TasksContext.Provider>
);
}الحل الصحيح هو تقسيم الـ Context إلى عدة Contexts أصغر، كل واحد مسؤول عن جزء محدد من البيانات. بالإضافة إلى ذلك، يمكنك استخدام مكتبات مثل Zustand أو Redux Toolkit لإدارة الـ State بدلاً من الاعتماد على الـ Context وحده. إليك كيف يمكن إصلاح هذا الكود باستخدام Zustand:
// ✅ الحل الصحيح: استخدام Zustand لإدارة الـ State
import create from 'zustand';
const useTasksStore = create((set) => ({
tasks: [],
addTask: (task) => set((state) => ({ tasks: [...state.tasks, task] })),
}));
// في المكون
function AddTaskButton() {
const addTask = useTasksStore(state => state.addTask);
return <button {() => addTask({ id: Date.now(), text: 'New Task' })}>Add Task</button>;
}
function TasksList() {
const tasks = useTasksStore(state => state.tasks);
return (
<ul>
{tasks.map(task => <li key={task.id}>{task.text}</li>)}
</ul>
);
}هذا الحل يضمن أن المكونات التي تستهلك الـ State يتم إعادة رندرها فقط عند تغير البيانات التي تعتمد عليها، وليس عند تغير أي جزء من الـ State. بالإضافة إلى ذلك، Zustand يوفر أدوات متقدمة مثل الـ Persistence والـ Middlewares التي تجعل إدارة الـ State أسهل وأكثر كفاءة.
بعد أكثر من عقد من كتابة React في مشاريع حقيقية، هذه هي النصائح العملية التي أطبقها في كل مشروع:
في النهاية، كتابة React بطريقة صحيحة ليست مجرد مسألة معرفة الـ Hooks أو الـ APIs، بل فهم كيف يعمل React خلف الكواليس وكيفية كتابة كود فعال. عندما تفهم هذه المبادئ، ستكتب تطبيقات أسرع وأكثر استقراراً وأسهل في الصيانة. تذكر دائماً: الكود الذي يعمل ليس بالضرورة الكود الجيد. الكود الجيد هو الذي يعمل بكفاءة ويمكن صيانته بسهولة.
الخطوة التالية؟ افتح مشروعك الحالي وابحث عن useEffect كبير أو مكون يعيد إنشاء الدوال في كل رندر. قم بإصلاح هذه المشاكل واحداً تلو الآخر، وقس الأداء قبل وبعد الإصلاح. سترى الفرق بنفسك.