هل تستخدم useState لكل شيء؟ أم تفرط في Redux بلا داعٍ؟ اكتشف متى تختار Context، Redux، Zustand، أو حتى Server State، وكيف يؤثر كل خيار على أداء تطبيقك في الإنتاج.
في أحد المشاريع الكبيرة التي عملت عليها مع فريق في شركة ناشئة، كان لدينا مكون واحد فقط مسؤول عن إدارة الـ Cart في تطبيق e-commerce. بدأنا بـ useState بسيط، ثم أضفنا Context لتمرير البيانات عبر 7 مستويات من المكونات، ثم انتقلنا إلى Redux لأننا ظننا أن المشكلة هي حجم الحالة. بعد شهرين، كان الكود بطيئاً لدرجة أن المستخدمين كانوا يغادرون الصفحة قبل تحميلها. الحقيقة التي اكتشفناها متأخراً؟ لم تكن المشكلة في حجم الحالة، بل في كيفية إدارتها. كان لدينا 15 re-render غير ضروري لكل تحديث صغير في الكمية، وكلها بسبب إعادة إنشاء الـ Context Provider في كل مرة. هذا المقال هو ما تمنيت لو قرأته قبل أن أضيع شهرين في Debugging.
إدارة الحالة في React ليست مجرد اختيار بين useState و Redux. إنها قرار هندسي يؤثر على أداء التطبيق، وصيانة الكود، وحتى تجربة المطورين. المشكلة الأكبر ليست في الأدوات نفسها، بل في عدم فهم متى تستخدم ماذا ولماذا. في هذا الدليل، سنفكك كل خيار متاح، ونشرح ما يحدث خلف الكواليس في الذاكرة والمعالج، ونحدد بالضبط متى يكون كل حل مناسباً بناءً على حجم الحالة، وتكرار التحديثات، وتعقيد البيانات.
الكثير من المطورين يقللون من قوة useState بحجة أنه "بدائي". لكن الحقيقة هي أن useState هو الخيار الأمثل عندما تكون الحالة محلية تماماً ولا تحتاج إلى مشاركتها عبر مكونات متعددة. المشكلة تبدأ عندما نحاول استخدامه لحالات تحتاج إلى مشاركة، مما يؤدي إلى ما يسمى بـ "Prop Drilling" - تمرير البيانات عبر 5 أو 6 مستويات من المكونات فقط للوصول إلى المكان الذي يحتاجها. لكن عندما تكون الحالة فعلاً محلية، مثل قيمة input أو حالة فتح/إغلاق modal، فإن useState يقدم أداءً ممتازاً لأنه لا يسبب re-render إلا للمكون الذي يستخدمه.
من الناحية التقنية، useState يخزن القيمة في الـ Closure الخاص بالمكون، ويتم تحديثها عبر الـ React Fiber الذي يقوم بعملية الـ Reconciliation. عندما تتغير الحالة، React يعيد حساب الـ Virtual DOM فقط للمكون الذي يحتوي على useState، وليس للشجرة بأكملها. هذا يعني أن تحديث حالة محلية بـ useState هو عملياً أسرع خيار ممكن في React. لكن هناك فخ كبير هنا: إذا استخدمت useState لحالة مشتركة بين مكونات متعددة، ستضطر إلى رفع الحالة إلى أقرب parent مشترك، مما قد يسبب re-render لكل المكونات الموجودة بين الـ parent والـ child الذي يحتاج الحالة.
// مثال صحيح: حالة محلية تماماً
function Counter() {
const [count, setCount] = useState(0);
// هذا التحديث يسبب re-render لهذا المكون فقط
const increment = () => setCount(c => c + 1);
return (
<div>
<button {increment}>Increment</button>
<p>Count: {count}</p>
</div>
);
}
// مثال خاطئ: استخدام useState لحالة مشتركة
function Parent() {
const [user, setUser] = useState(null); // ❌ يسبب re-render لكل Children
return (
<div>
<Header user={user} />
<Sidebar user={user} />
<MainContent user={user} />
</div>
);
}Context API هو الحل الرسمي من React لمشكلة Prop Drilling، لكنه غالباً ما يُساء استخدامه. الفكرة الأساسية هي إنشاء مخزن مركزي يمكن لأي مكون الوصول إليه دون الحاجة إلى تمرير البيانات عبر كل المستويات. لكن المشكلة تكمن في أن أي تحديث في Context يسبب re-render لكل المكونات التي تستهلكه، حتى لو لم تستخدم القيمة المحددة التي تغيرت. هذا يعني أنك قد تنتهي بمشكلة أسوأ من Prop Drilling: re-render غير ضروري لكل مكونات التطبيق عند أي تحديث بسيط.
خلف الكواليس، Context يعمل عبر إنشاء شجرة من الـ Providers، وكل provider يحتفظ بمرجع للقيمة الحالية. عندما تتغير القيمة، React يقوم بعملية تسمى "Context Propagation" حيث يرسل القيمة الجديدة إلى كل المستهلكين. المشكلة هنا هي أن React لا يعرف أي جزء من القيمة تغير، لذلك يعيد حساب كل المستهلكين. هذا يختلف عن Redux مثلاً، الذي يستخدم مقارنة عميقة لتحديد ما إذا كان هناك تغيير حقيقي أم لا. في التطبيق الذي ذكرت في البداية، كان لدينا Context يحتوي على 5 قيم مختلفة، وتحديث أي منها كان يسبب re-render لـ 40 مكوناً، مما أدى إلى بطء ملحوظ في واجهة المستخدم.
// مثال خاطئ: Context واحد لكل شيء
const AppC createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
const [theme, setTheme] = useState('light');
// أي تحديث لأي قيمة يسبب re-render لكل المستهلكين
const value = { user, setUser, cart, setCart, theme, setTheme };
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
// الحل الأفضل: تقسيم Context حسب الاستخدام
const UserContext = createContext();
const CartContext = createContext();
const ThemeContext = createContext();السر في استخدام Context بكفاءة هو تقسيمه إلى وحدات صغيرة بدلاً من إنشاء Context واحد ضخم. بدلاً من وضع كل الحالة في Context واحد، قم بإنشاء Contextات منفصلة لكل مجموعة من البيانات ذات الصلة. مثلاً، يمكن أن يكون لديك Context منفصل للمستخدم، وآخر للسلة، وآخر للإعدادات. بهذه الطريقة، تحديث السلة مثلاً لن يسبب re-render للمكونات التي تستخدم بيانات المستخدم فقط.
هناك أيضاً حيلة تقنية تسمى "Context Selector" يمكن استخدامها لتقليل re-renders. الفكرة هي استخدام مكتبة مثل use-context-selector التي تسمح لك باختيار جزء محدد من Context بدلاً من استهلاكه بالكامل. هذا مشابه لكيفية عمل mapStateToProps في Redux، حيث يمكنك تحديد البيانات التي تحتاجها فقط. في أحد المشاريع، استخدمنا هذه المكتبة لتقليل عدد re-renders من 40 إلى 3 فقط عند تحديث السلة، مما حسن أداء التطبيق بشكل ملحوظ.
Redux هو الحل الأشهر لإدارة الحالة في تطبيقات React الكبيرة، لكنه غالباً ما يُستخدم في أماكن لا يحتاج إليها. الفكرة الأساسية في Redux هي وجود مخزن مركزي واحد للحالة، وتحديثات تتم عبر أفعال (actions) يتم معالجتها بواسطة reducers نقية. الميزة الكبرى لـ Redux هي قدرته على التعامل مع الحالات المعقدة التي تحتاج إلى منطق تحديث متقدم، مثل الـ Middleware، الـ Thunks، أو حتى الـ Sagas. لكن هذه القوة تأتي بثمن: تعقيد إضافي في الكود، وحاجة إلى كتابة الكثير من الـ Boilerplate، وتأثير على الأداء بسبب المقارنات العميقة التي يقوم بها.
خلف الكواليس، Redux يستخدم نمط يسمى "Unidirectional Data Flow" حيث تتدفق البيانات في اتجاه واحد: من الـ Store إلى المكونات عبر الـ Actions و Reducers. عندما تقوم بإرسال action، Redux يقوم بتشغيل كل الـ Reducers المسجلين، ويقوم بمقارنة الحالة الجديدة بالحالة القديمة باستخدام خوارزمية تسمى "Shallow Equality Check". إذا كانت الحالة الجديدة مختلفة، يقوم Redux بإخطار كل المكونات المتصلة بالتغيير. المشكلة هنا هي أن هذه المقارنة تتم على كامل الحالة، مما قد يكون مكلفاً إذا كانت الحالة كبيرة. في أحد المشاريع، كان لدينا store يحتوي على أكثر من 5000 عنصر، وتحديث عنصر واحد كان يستغرق حوالي 200ms بسبب المقارنة العميقة، مما تسبب في تجمد واجهة المستخدم لبضع ثوانٍ عند كل تحديث.
// مثال على Redux مع منطق تحديث معقد
import { createSlice, configureStore } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: [],
reducers: {
addItem: (state, action) => {
const existingItem = state.find(item => item.id === action.payload.id);
if (existingItem) {
existingItem.quantity += 1;
} else {
state.push({ ...action.payload, quantity: 1 });
}
},
removeItem: (state, action) => {
return state.filter(item => item.id !== action.payload);
},
// منطق تحديث معقد يمكن إضافته هنا
}
});
const store = configureStore({
reducer: {
cart: cartSlice.reducer
}
});
// استخدام في المكون
function Cart() {
const cart = useSelector(state => state.cart);
const dispatch = useDispatch();
const handleAdd = (item) => {
dispatch(cartSlice.actions.addItem(item));
};
return (
<div>
{cart.map(item => (
<div key={item.id}>
{item.name} - {item.quantity}
<button {() => handleAdd(item)}>+</button>
</div>
))}
</div>
);
}Redux هو الخيار الأمثل عندما يكون لديك حالة معقدة تحتاج إلى منطق تحديث متقدم، أو عندما تحتاج إلى ميزات مثل الـ Time Travel Debugging، أو عندما يكون لديك فريق كبير يحتاج إلى هيكلية واضحة لإدارة الحالة. لكن إذا كان تطبيقك صغيراً أو متوسط الحجم، أو إذا كانت حالتك بسيطة ولا تحتاج إلى منطق تحديث معقد، فقد يكون Redux مبالغاً فيه. في هذه الحالات، قد يكون Zustand أو حتى Context مع بعض التحسينات خياراً أفضل.
هناك أيضاً مشكلة الأداء التي ذكرتها سابقاً. إذا كانت حالتك كبيرة وتحتوي على الكثير من البيانات، فإن المقارنات العميقة التي يقوم بها Redux قد تصبح مكلفة. في هذه الحالة، يمكنك استخدام مكتبات مثل reselect لإنشاء selectors مخصصة تقوم بحساب البيانات المشتقة بدلاً من تخزينها في الـ Store. هذا يقلل من حجم الحالة المخزنة ويحسن الأداء. في أحد المشاريع، استخدمنا reselect لتقليل حجم الـ Store من 5000 عنصر إلى 200 عنصر فقط، مما حسن أداء التطبيق بشكل كبير.
Zustand هو مكتبة حديثة لإدارة الحالة في React تقدم بديلاً خفيف الوزن لـ Redux. الفكرة الأساسية في Zustand هي إنشاء مخزن مركزي يمكن الوصول إليه من أي مكون دون الحاجة إلى Context أو Prop Drilling. الميزة الكبرى لـ Zustand هي بساطته وأدائه العالي، حيث يستخدم نفس مبدأ الـ Closures الذي تستخدمه useState، لكنه يسمح بمشاركة الحالة عبر المكونات. هذا يجعله خياراً ممتازاً للتطبيقات المتوسطة الحجم التي تحتاج إلى إدارة حالة بسيطة دون تعقيد Redux.
خلف الكواليس، Zustand يستخدم نمط يسمى "Flux-like" مشابه لـ Redux، لكنه يبسط الكثير من التفاصيل. بدلاً من Actions و Reducers، يمكنك ببساطة تحديث الحالة مباشرة عبر setters، مما يقلل من كمية الـ Boilerplate بشكل كبير. Zustand أيضاً يدعم الـ Middleware ويمكنه التعامل مع منطق تحديث معقد إذا احتجت إليه. لكن الميزة الحقيقية لـ Zustand تكمن في أدائه: لأنه لا يستخدم Context، فإنه لا يسبب re-renders غير ضرورية مثل Context API. بدلاً من ذلك، Zustand يستخدم اشتراكات مخصصة لكل مكون، مما يعني أن المكون يعيد الحساب فقط عندما تتغير البيانات التي يستخدمها بالفعل.
import create from 'zustand';
// إنشاء مخزن Zustand
const useStore = create((set) => ({
cart: [],
addItem: (item) => set((state) => {
const existingItem = state.cart.find(i => i.id === item.id);
if (existingItem) {
return {
cart: state.cart.map(i =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
)
};
} else {
return { cart: [...state.cart, { ...item, quantity: 1 }] };
}
}),
removeItem: (id) => set((state) => ({
cart: state.cart.filter(item => item.id !== id)
})),
}));
// استخدام في المكون
function Cart() {
const { cart, addItem, removeItem } = useStore();
return (
<div>
{cart.map(item => (
<div key={item.id}>
{item.name} - {item.quantity}
<button {() => addItem(item)}>+</button>
<button onClick={() => removeItem(item.id)}>-</button>
</div>
))}
</div>
);
}Zustand هو الخيار الأمثل عندما تريد حلاً بسيطاً وفعالاً لإدارة الحالة دون تعقيد Redux أو مشاكل الأداء في Context API. إنه مثالي للتطبيقات المتوسطة الحجم التي تحتاج إلى مشاركة حالة بين مكونات متعددة دون الحاجة إلى منطق تحديث معقد جداً. Zustand أيضاً خيار جيد إذا كنت تريد تقليل كمية الـ Boilerplate في الكود، حيث يمكنك إنشاء مخزن كامل في أقل من 20 سطراً من الكود.
هناك أيضاً ميزة أخرى لـ Zustand وهي دعمه للـ Persistence. يمكنك بسهولة حفظ الحالة في localStorage أو أي مكان آخر باستخدام Middleware بسيط. في أحد المشاريع، استخدمنا Zustand مع Middleware لحفظ حالة السلة في localStorage، مما سمح للمستخدمين بالاحتفاظ ببياناتهم حتى بعد إغلاق المتصفح. هذا النوع من الميزات كان سيتطلب الكثير من الكود الإضافي في Redux، لكنه بسيط جداً في Zustand.
في كثير من التطبيقات الحديثة، تأتي معظم البيانات من السيرفر وليس من العميل. هذا النوع من البيانات يسمى "Server State" وهو يختلف تماماً عن الـ Client State الذي تحدثنا عنه حتى الآن. المشكلة مع Server State هي أنه يتطلب إدارة مختلفة تماماً: تحتاج إلى التعامل مع الـ Loading و Error States، والتحديثات في الخلفية، والتخزين المؤقت، والمزامنة مع السيرفر. استخدام أدوات مثل useState أو Redux لإدارة Server State هو خطأ شائع يؤدي إلى الكثير من المشاكل، مثل البيانات القديمة، أو الـ Race Conditions، أو إعادة جلب البيانات بدون داعٍ.
خلف الكواليس، إدارة Server State تتطلب التعامل مع عدة تحديات: أولاً، تحتاج إلى طريقة لجلب البيانات من السيرفر، وهذا يتطلب التعامل مع الـ Network Requests و الـ Retries و الـ Timeouts. ثانياً، تحتاج إلى طريقة لتخزين البيانات مؤقتاً لتجنب إعادة جلبها في كل مرة. ثالثاً، تحتاج إلى طريقة لمزامنة البيانات المحلية مع البيانات على السيرفر، خاصة إذا كان هناك عدة مستخدمين يقومون بالتعديلات في نفس الوقت. هذه المشاكل لا يمكن حلها باستخدام أدوات مثل useState أو Redux لأنها ببساطة لم تُصمم لهذا الغرض.
// مثال على استخدام React Query لإدارة Server State
import { useQuery, useMutation, queryCache } from 'react-query';
// جلب البيانات من السيرفر
function useTodos() {
return useQuery('todos', async () => {
const res = await fetch('/api/todos');
if (!res.ok) throw new Error('Network response was not ok');
return res.json();
});
}
// تحديث البيانات على السيرفر
function useAddTodo() {
return useMutation(async (newTodo) => {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
if (!res.ok) throw new Error('Network response was not ok');
return res.json();
}, {
onSuccess: () => {
// إعادة جلب البيانات بعد التحديث
queryCache.invalidateQueries('todos');
},
});
}
// استخدام في المكون
function Todos() {
const { data: todos, isLoading, error } = useTodos();
const [addTodo] = useAddTodo();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{todos.map(todo => (
<div key={todo.id}>{todo.title}</div>
))}
<button {() => addTodo({ title: 'New Todo' })}>
Add Todo
</button>
</div>
);
}هناك عدة مكتبات مصممة خصيصاً لإدارة Server State، أشهرها React Query و SWR. هاتان المكتبتان تقدمان حلولاً متكاملة لإدارة جلب البيانات، والتخزين المؤقت، والمزامنة مع السيرفر. الميزة الكبرى لهذه المكتبات هي أنها تتعامل مع كل التعقيدات خلف الكواليس، مما يسمح لك بالتركيز على منطق التطبيق بدلاً من إدارة البيانات. على سبيل المثال، React Query يدعم ميزات مثل الـ Background Refetching، و الـ Optimistic Updates، و الـ Pagination، وكلها ميزات تحتاج إلى الكثير من الكود الإضافي إذا حاولت تنفيذها بنفسك باستخدام useState أو Redux.
في أحد المشاريع الكبيرة، كنا نستخدم Redux لإدارة Server State، وكان لدينا أكثر من 500 سطر من الكود فقط لإدارة جلب البيانات والتخزين المؤقت. بعد الانتقال إلى React Query، تمكنا من تقليل هذا الكود إلى أقل من 100 سطر، مع تحسين الأداء بشكل ملحوظ. السبب هو أن React Query يدير كل شيء تلقائياً: التخزين المؤقت، وإعادة المحاولة عند الفشل، والتحديثات في الخلفية، وحتى الـ Race Conditions. كل هذا يتم بدون أي تدخل منك، مما يجعل الكود أكثر نظافة وصيانة.
بعد أكثر من عشر سنوات في تطوير تطبيقات React، هذه هي الخارطة التي أستخدمها لاختيار أداة إدارة الحالة المناسبة لكل مشروع:
النصيحة الذهبية التي أتمنى لو عرفتها منذ البداية: لا تختر الأداة بناءً على شعبيتها أو تعقيدها، بل اخترها بناءً على احتياجات مشروعك الحقيقية. الكثير من المطورين يستخدمون Redux لأن "كل المشاريع الكبيرة تستخدمه"، لكنهم ينتهي بهم الأمر بكود معقد بدون داعٍ. في المقابل، الكثيرون يستخدمون useState لحالات معقدة لأنهم "لا يريدون إضافة مكتبات إضافية"، لكنهم ينتهي بهم الأمر بمشاكل أداء وصيانة. القاعدة البسيطة هي: ابدأ بالحل الأبسط، ثم انتقل إلى حلول أكثر تعقيداً فقط عندما تحتاج إليها فعلاً.
الخطوة التالية؟ اختر مشروعاً صغيراً وجرب كل هذه الأدوات فيه. قم بقياس أداء كل حل باستخدام أدوات مثل React DevTools، وشاهد بنفسك الفرق بين re-render واحد و 40 re-render عند تحديث حالة بسيطة. هذه التجربة العملية ستعطيك فهماً أعمق بكثير من أي مقال أو توثيق. وعندما تواجه مشكلة حقيقية في مشروعك التالي، ستعرف بالضبط أي أداة تختار ولماذا.