اكتشف الأخطاء الشائعة في كتابة React التي تبطئ تطبيقاتك وتسبب تسريبات الذاكرة، وكيفية تجنبها بأمثلة عملية من تجارب حقيقية في شركات التكنولوجيا الكبرى.
في أحد المشاريع الكبيرة الذي عملت عليه مع فريق مكون من 15 مطوراً، لاحظنا أن تطبيق React الذي كنا نبنيه بدأ يتباطأ بشكل ملحوظ بعد إضافة بضعة مكونات فقط. لم يكن السبب في بطء الشبكة أو السيرفر، بل في الطريقة التي كنا نكتب بها الكود. عندما قمنا بتحليل الأداء باستخدام أدوات مثل React DevTools وLighthouse، وجدنا أن 70% من وقت التحميل كان يضيع في عمليات إعادة الرسم غير الضرورية (re-renders) بسبب استخدام غير صحيح لـ useEffect وuseState. هذه ليست مشكلة فردية، بل نمط شائع أراه في معظم أكواد React التي أراجعها، سواء في مشاريع مفتوحة المصدر أو في شركات ناشئة وحتى في فرق كبيرة. المشكلة ليست في React نفسها، بل في كيفية فهم المطورين لمفاهيمها الأساسية وكيفية تطبيقها في العالم الحقيقي.
الحقيقة هي أن معظم المطورين يتعلمون React من خلال الأمثلة البسيطة والدروس السريعة التي تركز على كتابة الكود بسرعة دون التفكير في الأداء أو قابلية الصيانة. النتيجة؟ تطبيقات تبدو تعمل بشكل جيد في بيئة التطوير، لكنها تنهار تحت ضغط المستخدمين الحقيقيين. في هذا المقال، سأفكك الأخطاء الشائعة التي أراها يومياً في كتابة React، وأشرح لماذا تحدث، وما هي الحلول العملية لتجنبها. لن نتحدث عن النظريات الأكاديمية، بل عن مشاكل حقيقية واجهتها في مشاريع حقيقية وحلول أثبتت فعاليتها في الإنتاج.
من أكثر الأخطاء شيوعاً التي أراها هو استخدام useEffect كوسيلة لتنفيذ أي منطق جانبـي (side effects) دون التفكير في تبعاته. المطورون يضعون كل شيء داخل useEffect، بدءاً من جلب البيانات وحتى التعامل مع الأحداث المحلية، ظناً منهم أن هذا هو المكان الصحيح لكل شيء يحدث بعد الرسم الأولي. المشكلة هنا ليست فقط في الأداء، بل في التعقيد الذي يضيفه هذا النهج إلى الكود. عندما تضع منطقاً غير مرتبط ببعضه داخل نفس useEffect، فإنك تجعل الكود صعب الفهم وصعب الاختبار وصعب التعديل.
لنأخذ مثالاً واقعياً: في أحد المشاريع، كان هناك مكون يعرض قائمة من المستخدمين ويتيح فلترتهم بناءً على حالة معينة. المطور كتب الكود التالي، معتقداً أنه الحل الأمثل:
// ❌ مثال على استخدام خاطئ لـ useEffect
import React, { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [filter, setFilter] = useState('all');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setIsLoading(false);
});
// فلترة المستخدمين بناءً على الحالة
const filteredUsers = users.filter(user => {
if (filter === 'active') return user.isActive;
if (filter === 'inactive') return !user.isActive;
return true;
});
setUsers(filteredUsers);
}, [filter]); // ❌ هذا التبعية خاطئة!
return (
<div>
<select {(e) => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
{isLoading ? <p>Loading...</p> : (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}هذا الكود يحتوي على عدة مشاكل جوهرية. أولاً، الـ useEffect يعتمد على filter فقط، لكنه يحاول الوصول إلى users الذي لم يتم تحديثه بعد بسبب الطبيعة غير المتزامنة لـ setState. ثانياً، الفلترة تتم داخل useEffect مما يسبب إعادة رسم غير ضرورية. ثالثاً، الكود غير قابل للاختبار بسهولة لأنه يجمع بين جلب البيانات والمنطق المحلي في نفس المكان. الحل الصحيح هو فصل هذه المسؤوليات واستخدام أدوات مثل useMemo للفلترة:
// ✅ الحل الصحيح: فصل المسؤوليات واستخدام useMemo
import React, { useState, useEffect, useMemo } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [filter, setFilter] = useState('all');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setIsLoading(false);
});
}, []); // جلب البيانات مرة واحدة فقط
const filteredUsers = useMemo(() => {
return users.filter(user => {
if (filter === 'active') return user.isActive;
if (filter === 'inactive') return !user.isActive;
return true;
});
}, [users, filter]); // ✅ التبعيات الصحيحة
return (
<div>
<select {(e) => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
{isLoading ? <p>Loading...</p> : (
<ul>
{filteredUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}هذا الكود أكثر كفاءة لأنه يفصل بين جلب البيانات والمنطق المحلي، ويستخدم useMemo لتجنب إعادة الحسابات غير الضرورية. لاحظ كيف أن الفلترة تتم فقط عندما يتغير users أو filter، وليس في كل مرة يتم فيها رسم المكون. هذا النوع من التفكير في الأداء هو ما يميز الكود الجيد عن الكود الذي يعمل فحسب.
من أكثر المفاهيم التي يتم تجاهلها في React هو كيفية تعامل الـ Hooks مع الـ Closures في JavaScript. عندما تستخدم useState أو useEffect، فإن القيم التي يتم التقاطها داخل الـ Closure تكون ثابتة بناءً على وقت إنشاء الـ Closure، وليس وقت التنفيذ. هذا يعني أنك قد تجد نفسك تستخدم قيمة قديمة لـ state أو props دون أن تدرك ذلك، خاصة في الـ Event Handlers أو الـ Callbacks غير المتزامنة.
لنأخذ مثالاً شائعاً: مكون يعرض زراً يقوم بزيادة عداد عند النقر عليه. يبدو بسيطاً، لكن الكثير من المطورين يكتبونه بطريقة خاطئة تؤدي إلى سلوك غير متوقع:
// ❌ مثال على مشكلة الـ Closure في الـ Hooks
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // ❌ count هنا ثابت في الـ Closure
}, 1000);
return () => clearInterval(timer);
}, []); // ❌ لا توجد تبعيات!
return <div>Count: {count}</div>;
}في هذا المثال، الـ setInterval يلتقط القيمة الأولية لـ count (0) ويستخدمها في كل مرة، مما يعني أن الـ count لن يزيد أبداً عن 1. هذا لأن الـ Closure الذي تم إنشاؤه عند بدء الـ Interval لا يرى التحديثات اللاحقة لـ count. الحل الصحيح هو استخدام النسخة الوظيفية من setCount التي تستقبل القيمة الحالية:
// ✅ الحل الصحيح باستخدام النسخة الوظيفية من setState
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prevCount => prevCount + 1); // ✅ استخدام القيمة الحالية
}, 1000);
return () => clearInterval(timer);
}, []); // ✅ لا توجد تبعيات لأننا نستخدم النسخة الوظيفية
return <div>Count: {count}</div>;
}هذه المشكلة ليست مقتصرة على useEffect فقط، بل تظهر أيضاً في الـ Event Handlers. على سبيل المثال، إذا كان لديك زر يقوم بإرسال طلب API بناءً على قيمة state، فقد ينتهي بك الأمر بإرسال القيمة القديمة إذا لم تكن حذراً:
// ❌ مشكلة الـ Closure في الـ Event Handlers
function UserProfile() {
const [user, setUser] = useState({ name: '', email: '' });
const handleSubmit = () => {
fetch('/api/update', {
method: 'POST',
body: JSON.stringify(user), // ❌ قد تكون هذه القيمة قديمة!
});
};
return (
<form>
<input
type="text"
value={user.name}
{(e) => setUser({ ...user, name: e.target.value })}
/>
<button onClick={handleSubmit}>Submit</button>
</form>
);
}الحل هنا هو إما استخدام ref لتتبع القيمة الحالية، أو إعادة تعريف الـ Handler في كل مرة يتغير فيها الـ state. الحل الثاني هو الأكثر شيوعاً في React:
// ✅ الحل باستخدام useCallback مع التبعيات الصحيحة
import React, { useState, useCallback } from 'react';
function UserProfile() {
const [user, setUser] = useState({ name: '', email: '' });
const handleSubmit = useCallback(() => {
fetch('/api/update', {
method: 'POST',
body: JSON.stringify(user), // ✅ القيمة الحالية
});
}, [user]); // ✅ التبعية الصحيحة
return (
<form>
<input
type="text"
value={user.name}
{(e) => setUser({ ...user, name: e.target.value })}
/>
<button onClick={handleSubmit}>Submit</button>
</form>
);
}واحدة من أكبر المشاكل التي تواجه تطبيقات React هي إعادة الرسم غير الضرورية (unnecessary re-renders). عندما يتغير state أو props في مكون، فإن React يعيد رسم هذا المكون وكل مكوناته الفرعية. في التطبيقات الصغيرة، قد لا تلاحظ هذا التأثير، لكن في التطبيقات الكبيرة، يمكن أن يؤدي هذا إلى بطء ملحوظ في الأداء، خاصة إذا كانت المكونات الفرعية ثقيلة أو تحتوي على منطق معقد.
لنأخذ مثالاً واقعياً من مشروع حقيقي: في إحدى منصات التجارة الإلكترونية التي عملت عليها، كان هناك مكون يعرض قائمة المنتجات مع فلترة وتصفية. المكون الرئيسي كان يعيد الرسم في كل مرة يتغير فيها فلتر أو حالة التصفية، مما كان يسبب إعادة رسم مكونات فرعية مثل ProductCard وPriceFilter حتى لو لم تتأثر بالتغيير. بعد تحليل الأداء، وجدنا أن 60% من وقت الرسم كان يضيع في إعادة رسم مكونات لم تتغير بياناتها بالفعل.
الحل لهذه المشكلة هو استخدام تقنيات تحسين الأداء في React مثل React.memo وuseMemo وuseCallback. لكن الكثير من المطورين يستخدمون هذه الأدوات بشكل عشوائي دون فهم متى وكيف يجب استخدامها. على سبيل المثال، استخدام React.memo لكل مكون قد يبدو حلاً جيداً، لكنه في الواقع يمكن أن يؤدي إلى تأثير عكسي إذا كانت الـ props التي يتم تمريرها تتغير بشكل متكرر:
// ❌ استخدام خاطيء لـ React.memo
import React, { useState } from 'react';
const ExpensiveComp React.memo(function ExpensiveComponent({ data }) {
// عملية حسابية ثقيلة هنا
return <div>{data.value}</div>;
});
function ParentComponent() {
const [count, setCount] = useState(0);
const data = { value: 'Hello' };
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<ExpensiveComponent data={data} /> {/* ❌ سيُعاد الرسم في كل مرة */}
</div>
);
}في هذا المثال، الـ ExpensiveComponent سيتم إعادة رسمه في كل مرة يتم فيها النقر على الزر، لأن الكائن data يتم إنشاؤه من جديد في كل مرة يتم فيها رسم الـ ParentComponent. الحل الصحيح هو إما استخدام useMemo لإنشاء الكائن مرة واحدة فقط، أو إعادة هيكلة الكود بحيث لا يتم تمرير كائنات جديدة في كل مرة:
// ✅ الحل الصحيح باستخدام useMemo
import React, { useState, useMemo } from 'react';
const ExpensiveComp React.memo(function ExpensiveComponent({ data }) {
// عملية حسابية ثقيلة هنا
return <div>{data.value}</div>;
});
function ParentComponent() {
const [count, setCount] = useState(0);
const data = useMemo(() => ({ value: 'Hello' }), []); // ✅ إنشاء الكائن مرة واحدة
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<ExpensiveComponent data={data} /> {/* ✅ لن يُعاد الرسم */}
</div>
);
}من المهم أيضاً فهم أن React.memo ليس حلاً سحرياً. في بعض الحالات، قد يكون من الأفضل إعادة هيكلة الكود بالكامل لتجنب إعادة الرسم بدلاً من الاعتماد على التحسينات السطحية. على سبيل المثال، إذا كان لديك مكون كبير يحتوي على منطق معقد، فقد يكون من الأفضل تقسيمه إلى مكونات أصغر وأكثر تخصصاً بحيث يتم إعادة رسم الأجزاء المتأثرة فقط بالتغييرات.
إدارة الحالة العالمية هي واحدة من أصعب التحديات في بناء تطبيقات React الكبيرة. الكثير من المطورين يلجأون إلى حلول مثل Redux أو Context API دون فهم متى وكيف يجب استخدامها. النتيجة هي تطبيقات معقدة بشكل غير ضروري، حيث يتم تمرير الحالة عبر عدة مستويات من المكونات، أو تطبيقات تعتمد بشكل مفرط على الحالة العالمية مما يجعلها صعبة الاختبار والصيانة.
لنأخذ مثالاً من مشروع حقيقي: في إحدى منصات إدارة المحتوى التي عملت عليها، كان الفريق يستخدم Redux لإدارة كل شيء، بدءاً من حالة تسجيل الدخول وحتى فلترة القوائم. النتيجة كانت متجر Redux يحتوي على أكثر من 50 reducer، وكل مكون كان متصلاً بهذا المتجر حتى لو كان يحتاج إلى جزء صغير جداً من البيانات. هذا أدى إلى عدة مشاكل:
الحل الذي اعتمدناه كان إعادة هيكلة إدارة الحالة باستخدام مزيج من الحلول المحلية والعالمية. استخدمنا Context API فقط للبيانات التي تحتاج إلى الوصول إليها من قبل عدة مكونات غير مرتبطة ببعضها مباشرة، مثل حالة المستخدم الحالي. أما بالنسبة للبيانات المحلية، فقد استخدمنا state المحلي داخل المكونات أو hooks مخصصة. بالنسبة للبيانات التي تحتاج إلى مشاركة بين مكونات متجاورة، استخدمنا نمط رفع الحالة (lifting state up) بدلاً من استخدام الحالة العالمية.
// ✅ مثال على إدارة حالة متوازنة
import React, { createContext, useContext, useState } from 'react';
// 1. الحالة العالمية (Context) فقط لما يحتاج إلى مشاركة عميقة
const AuthC createContext();
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = (userData) => {
setUser(userData);
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
// 2. الحالة المحلية للمكونات التي لا تحتاج إلى مشاركة
function UserProfile() {
const [profile, setProfile] = useState({ name: '', bio: '' });
const { user } = useAuth();
// ... منطق المكون
}
// 3. رفع الحالة للمكونات المتجاورة
function ParentComponent() {
const [filter, setFilter] = useState('all');
return (
<div>
<FilterControls filter={filter} setFilter={setFilter} />
<ProductList filter={filter} />
</div>
);
}هذا النهج يجعل الكود أكثر قابلية للصيانة واختباراً، ويقلل من إعادة الرسم غير الضرورية. من المهم أيضاً التفكير في متى يجب استخدام مكتبات مثل Redux أو Zustand. في معظم الحالات، لا تحتاج إلى Redux إلا إذا كان لديك حالة معقدة تحتاج إلى إدارة مركزية مع ميزات مثل middleware وtime-travel debugging. بالنسبة لمعظم التطبيقات، يمكن استخدام مزيج من Context API وstate المحلي وhooks مخصصة.
من أكثر الأخطاء التي أراها في تطبيقات React هو تجاهل تأثير الـ Side Effects على تجربة المستخدم. سواء كان الأمر يتعلق بجلب البيانات أو التعامل مع الأحداث، فإن الطريقة التي تدير بها الـ Side Effects يمكن أن تجعل الفرق بين تطبيق سريع الاستجابة وتطبيق يشعر المستخدم بأنه بطيء أو غير مستقر.
لنأخذ مثالاً شائعاً: جلب البيانات عند تحميل المكون. الكثير من المطورين يكتبون الكود التالي دون التفكير في تجربة المستخدم:
// ❌ جلب البيانات بدون مراعاة تجربة المستخدم
import React, { useState, useEffect } from 'react';
function ProductList() {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setIsLoading(false);
})
.catch(err => {
setError(err);
setIsLoading(false);
});
}, []);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}هذا الكود يبدو بريئاً، لكنه يحتوي على عدة مشاكل تؤثر على تجربة المستخدم:
الحل هو استخدام مكتبات مثل React Query أو SWR التي تعالج هذه المشاكل بشكل افتراضي، أو تنفيذ هذه الميزات يدوياً. إليك مثال على كيفية تحسين هذا الكود:
// ✅ جلب البيانات مع مراعاة تجربة المستخدم
import React, { useState, useEffect } from 'react';
function ProductList() {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [retryCount, setRetryCount] = useState(0);
const [progress, setProgress] = useState(0); // للمؤشر على التقدم
useEffect(() => {
const c new AbortController();
const { signal } = controller;
const fetchData = async () => {
try {
setIsLoading(true);
setError(null);
// محاكاة جلب بيانات جزئية
const response = await fetch('/api/products', { signal });
const reader = response.body.getReader();
let receivedLength = 0;
let chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
setProgress(Math.round((receivedLength / response.headers.get('Content-Length')) * 100));
}
const data = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
data.set(chunk, position);
position += chunk.length;
}
setProducts(JSON.parse(new TextDecoder().decode(data)));
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
} finally {
setIsLoading(false);
}
};
fetchData();
return () => {
controller.abort(); // إلغاء الطلب عند إلغاء المكون
};
}, [retryCount]); // إعادة المحاولة عند تغيير retryCount
if (isLoading && progress === 0) return <div>جارٍ تحميل البيانات...</div>;
if (isLoading) return <div>جارٍ تحميل: {progress}%</div>;
if (error) return (
<div>
<p>حدث خطأ: {error.message}</p>
<button onClick={() => setRetryCount(c => c + 1)}>إعادة المحاولة</button>
</div>
);
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}هذا الكود يعالج معظم المشاكل التي ذكرناها، لكنه معقد ويصعب صيانته. لهذا السبب أوصي باستخدام مكتبات مثل React Query التي توفر هذه الميزات بشكل افتراضي مع واجهة برمجة بسيطة:
// ✅ استخدام React Query لجلب البيانات
import { useQuery } from 'react-query';
function ProductList() {
const { data: products, isLoading, error, refetch } = useQuery('products', () =>
fetch('/api/products').then(res => res.json())
);
if (isLoading) return <div>جارٍ تحميل البيانات...</div>;
if (error) return (
<div>
<p>حدث خطأ: {error.message}</p>
<button {() => refetch()}>إعادة المحاولة</button>
</div>
);
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}React Query توفر أيضاً ميزات متقدمة مثل التخزين المؤقت (caching)، وإعادة المحاولة التلقائية، والتحديث التلقائي للبيانات، وإلغاء الطلبات تلقائياً عند إلغاء المكون. استخدام هذه المكتبات ليس فقط يجعل الكود أكثر نظافة، بل يضمن أيضاً تجربة مستخدم أفضل بكثير.
بعد سنوات من العمل مع React في مشاريع مختلفة الأحجام والتعقيد، توصلت إلى بعض المبادئ الأساسية التي يجب اتباعها لكتابة كود React جيد:
في النهاية، كتابة React بالطريقة الصحيحة ليست فقط عن كتابة كود يعمل، بل عن كتابة كود قابل للصيانة، وفعال، ويوفر تجربة مستخدم ممتازة. لا تقع في فخ كتابة الكود بناءً على ما تعلمته من الدروس السريعة، بل فكر دائماً في كيفية عمل الكود خلف الكواليس وفي تأثيره على الأداء وتجربة المستخدم. تذكر أن React هي مجرد أداة، والطريقة التي تستخدم بها هذه الأداة هي ما يحدد جودة تطبيقك.
إذا كنت تريد تحسين مهاراتك في React، ابدأ بتحليل أداء تطبيقك الحالي باستخدام أدوات مثل React DevTools وLighthouse. ابحث عن المكونات التي تعيد الرسم بشكل متكرر، وابحث عن الـ useEffect التي تعتمد على قيم غير صحيحة، وحاول تحسينها باستخدام المبادئ التي ناقشناها. مع الوقت، ستطور حساً فطرياً لكتابة كود React جيد، وستلاحظ الفرق في أداء تطبيقاتك وسهولة صيانتها.