Zustand مع Next.js: التحديات والحلول
لو سألت أي مطور React عن مكتبته المفضلة لإدارة الـ State، بنسبة 90% هيقولك: “Zustand يا باشا!”. والسبب معروف:
- بسيطة (Minimalistic) جداً مقارنة بالعمالقة القدامى.
- مفيش Boilerplate (وداعاً Redux ومشاكل الـ Actions/Reducers).
- Hooks-based ومريح للأعصاب.
- حجمها صغير جداً (Bundle Size Friendly).
لكن… وبمجرد ما تقرر تدخل عالم Next.js (وتحديداً الـ App Router والـ SSR)، بتظهر تحديات تقنية معقدة. فجأة بتلاقي نفسك بتواجه مشاكل:
- الداتا بتاعت اليوزر (أ) بتظهر لليوزر (ب)! ودي مشكلة أمنية.
- الشاشة بتعمل وميض (Hydration Mismatch).
- الـ Console مليان أخطاء عن “Text content does not match server-rendered HTML”.
في المقال ده، إحنا مش جايين “نشغل الدنيا وخلاص”. إحنا جايين نفهم. هنفكك المشكلة من جذورها في الـ Memory Management، وهنبني Solution هندسي سليم يخليك تستخدم Zustand مع Next.js وأنت حاطط رجل على رجل، وهنقارنها بـ Redux، ونكتب Tests كمان.
المقال طويل جداً، ودسم، فركز معايا عشان الرحلة فيها تفاصيل كتير.
ليه إدارة الـ StateManagement بتبقى أصعب في السيرفر؟ (The Challenges of SSR)
عشان تفهم ليه Zustand بتحتاج تعامل خاص مع Next.js، لازم الأول نفهم الفرق الجوهري بين تشغيل الكود في المتصفح (SPA) وتشغيله على السيرفر (SSR).
1.1 في عالم الـ SPA (زي Vite أو CRA)
المتصفح بيحمل ملف JS واحد (Bundle). كل يوزر بيفتح الموقع بيحمل نسخته الخاصة من الكود في متصفحه هو (Browser Memory). لو عملت Global Variable، هو Global لليوزر ده بس، في جهازه هو بس.
// في متصفح أحمد: count = 5
// في متصفح منى: count = 0
// مفيش أي تداخل، لأن ده جهاز وده جهاز تاني خالص. وبينهم بحور ومحيطات.
1.2 في عالم الـ Next.js (SSR)
هنا اللعبة بتتغير تماماً. الكود بتاعك بيشتغل في مكانين:
- Server-Side: على سيرفر Node.js (أو Edge Runtime).
- Client-Side: في متصفح اليوزر (بعد ما الصفحة توصل).
المصيبة الحقيقية بتحصل في الـ Server. السيرفر هو “جهاز واحد” بيخدم آلاف المستخدمين في نفس الوقت. و Node.js بطبيعتها بتعمل Module Caching. يعني لو عرفت متغير خارج الـ Component، المتغير ده بيتحجز في الرامات مرة واحدة بس، وبيفضل عايش طول ما السيرفر شغال!
السيناريو التقني (The Scenario)
(تخيل معي رحلة الداتا: المستخدم “أحمد” بيبعت طلب للسيرفر، السيرفر بيكتب بيانات أحمد في الـ Global Store. بعدها بلحظة، المستخدمة “منى” بتبعت طلب، السيرفر بيقرأ من نفس الـ Global Store فيلاقي بيانات أحمد! وكأنك بتستخدم طبق واحد لكل الزبائن في المطعم من غير ما يتغسل.)
تخيل إنك عملت Store بالطريقة العادية دي (Single Instance):
// store.ts
import { create } from 'zustand'
export const useStore = create((set) => ({
currentUser: null,
login: (user) => set({ currentUser: user })
}))
ودلوقتي تعال نتخيل اللي بيحصل في السيرفر خطوة بخطوة:
- أحمد فتح الموقع. السيرفر بدأ يعالج الطلب.
- أحمد عمل تسجيل دخول. الـ Store اتعدل في رامات السيرفر:
currentUser = 'Ahmed'. - منى فتحت الموقع بعدها بثانية واحدة بس.
- السيرفر بيعمل Render لصفحة منى. بيقرأ من نفس الـ Store Instance اللي في الرامات.
- منى تلاقي نفسها مسجلة دخول باسم “أحمد”! وممكن تشوف بياناته الشخصية.
دي ظاهرة اسمها Cross-Request State Pollution (تلوث الحالة عبر الطلبات)، وهي من أصعب الحاجات اللي ممكن تحصل في تطبيقات الـ Backend/SSR. أنت حرفياً بتسرب داتا بين المستخدمين.
في Next.js، ممنوع منعاً باتاً استخدام Global Stores (Singletons) لتخزين داتا تخص يوزر بعينه أو ريكويست بعينه.
الحل الهندسي - نمط المصنع (The Factory Pattern)
المشكلة الأساسية اللي واجهتنا فوق هي إننا بنستخدم “نسخة واحدة” (Singleton) من الـ Store لكل اليوزرز. الحل البديهي إننا نبطل نعمل كده، ونبدأ نستخدم نمط “المصنع” (Factory Pattern).
يعني إيه Factory Pattern هنا؟
تخيل الفرق بين “البيت” و “رسمة البيت” (Blueprints):
- الـ Singleton: هو إنك تبني بيت واحد بس، وكل الناس تسكن فيه مع بعض (وده اللي بيعمل تداخل البيانات).
- الـ Factory: هو إنك تحتفظ بـ “رسمة البيت” (دالة)، وكل ما ييجي يوزر جديد، تبني له بيت خاص بيه هو بس (Store Instance) من الرسمة دي.
بمعنى تقني: بدل ما نعرف متغير store ثابت، هنعرف دالة createStore بتنشئ Store جديد وترجعه كل ما نناديها.
2.1 ليه لازم نستخدم zustand/vanilla؟
في React العادية، إحنا متعودين نستخدم create من zustand مباشرة، ودي بترجع لنا Hook (مثلاً useStore).
// الطريقة العادية (فعالة في SPA فقط)
const useStore = create((set) => ({ ... }))
المشكلة في الـ Next.js SSR إننا محتاجين ننشئ الـ Store بره دورة حياة الـ React Components (على مستوى السيرفر قبل الـ Rendering)، وعشان كده مينفعش نستخدم Hooks (لأن الـ Hooks لازم تعيش جوه Components).
هنا بييجي دور zustand/vanilla. دي النسخة “الخام” من Zustand، اللي بتسمح لنا ننشئ Store كـ Pure JavaScript Object، من غير أي ارتباط بـ React. ده بيدينا مرونة كاملة نتحكم في الـ Store:
- ننشؤه وقت ما نحب.
- نمرره في الـ Props.
- نربطه بـ React Context لاحقاً.
2.2 الحل: لكل ريكويست حكاية (Request-Scoped Store)
(الفكرة هنا ببساطة: السيرفر مش هيحفظ أي داتا عنده. السيرفر بس هيحتفظ بـ “المصنع”. أول ما “أحمد” يبعت طلب، السيرفر يشغل المصنع، يطلع Store فاضي جديد، يملاه ببيانات أحمد، ويبعته للـ Client. أول ما الرد يوصل لأحمد، نسخة الـ Store دي بتتمسح من ذاكرة السيرفر (Garbage Collection). لما “منى” تطلب، المصنع يشتغل تاني ويعمل Store جديد خالص ليها.)
2.3 مثال عملي: متجر إلكتروني (Shopping Cart)
تعال نطبق الكلام ده على Shopping Cart، خطوة بخطوة.
الخطوة 1: تعريف الأنواع (Types)
عشان نبني أساس قوي، لازم نعرف شكل الداتا بتاعتنا بوضوح باستخدام TypeScript.
// src/stores/cart-store.ts
import { createStore } from 'zustand/vanilla'
// 1. تعريف شكل المنتج (Data Model)
export type Product = {
id: string
name: string
price: number
}
// 2. تعريف عنصر السلة (بيزيد عليه الكمية)
export type CartItem = Product & { quantity: number }
// 3. شكل الداتا (State)
// دي البيانات اللي الـ Store هيشيلها
export type CartState = {
items: CartItem[]
isOpen: boolean
}
// 4. شكل الأكشنز (Actions)
// دي الطرق الوحيدة لتغيير الداتا
export type CartActions = {
addItem: (product: Product) => void
removeItem: (productId: string) => void
updateQuantity: (productId: string, quantity: number) => void
clearCart: () => void
toggleCart: () => void
}
// 5. نوع الـ Store الكامل (داتا + أكشنز)
export type CartStore = CartState & CartActions
// 6. الحالة الافتراضية (Default State)
// دي القيم اللي الـ Store بيبدأ بيها لو مفيش داتا جاية من السيرفر
export const defaultInitState: CartState = {
items: [],
isOpen: false,
}
الخطوة 2: دالة المصنع (The Creator Function)
دي أهم خطوة. دي الدالة اللي هتشتغل مع كل Request جديد. ملاحظة مهمة: إحنا هنا بنرجع createStore مش create.
// src/stores/cart-store.ts (تابع)
// دي الدالة "المصنع" اللي هنستخدمها في الـ Components
export const createCartStore = (
// بنسمح بتمرير state مبدئي (عشان الـ Hydration من السيرفر)
initState: CartState = defaultInitState,
) => {
// هنا بننشئ Store جديد تماماً في الذاكرة
return createStore<CartStore>()((set) => ({
// بنفك الحالة المبدئية (سواء الفاضية أو اللي جاية من السيرفر)
...initState,
// هنا بنكتب المنطق (Logic) بتاعنا
addItem: (product) => set((state) => {
// بنشوف لو المنتج موجود أصلاً
const existingItem = state.items.find(item => item.id === product.id)
if (existingItem) {
// لو موجود، بنعمل نسخة جديدة من الـ Array (Immutability) ونزود الكمية
return {
items: state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
}
// لو جديد، بنضيفه للـ Array مع كمية 1
return { items: [...state.items, { ...product, quantity: 1 }] }
}),
removeItem: (productId) => set((state) => ({
// بنشيل المنتج بالـ Filter
items: state.items.filter(item => item.id !== productId)
})),
updateQuantity: (productId, quantity) => set((state) => ({
// بنعدل كمية منتج معين
items: state.items.map(item =>
item.id === productId ? { ...item, quantity } : item
)
})),
clearCart: () => set({ items: [] }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
}))
}
كده إحنا جاهزون. الدالة createCartStore دي بقت هي “المفتاح” اللي بيضمن إن كل يوزر ياخد نسخته الخاصة، ومفيش أي تداخل يحصل بينهم.
الجسر - React Context (The Bridge)
دلوقتي عندنا “المصنع” (createCartStore)، لكن عندنا مشكلة تانية: إزاي نوصل الـ Store الجديد ده لكل الـ Components في شجرة التطبيق (Component Tree) من غير ما نستخدم Global Variable؟
الحل هو نمط تصميمي مشهور اسمه Dependency Injection، وهنطبقه في React باستخدام الـ Context.
3.1 ليه Context؟ مش Zustand بديل للـ Context؟
ده سؤال ذكي جداً!
- Zustand وظيفته إدارة الحالة (State Management) وتجنب الـ Re-renders الزائدة.
- Context وظيفته هنا مختلفة تماماً: هو مجرد “مواسير” (Transport Layer) عشان ننقل “كائن الـ Store” نفسه من قمة التطبيق لباقي المكونات.
إحنا مش هنحط الـ items أو الداتا جوه الـ Context (لأن ده هيعمل Performance Issues). إحنا هنحط الـ Store Instance بس.
3.2 إنشاء الـ Provider
تعال نعمل ملف جديد src/providers/cart-store-provider.tsx. ده المكون اللي “هيمسك” الـ Store ويوصله لأولاده.
'use client'
// لازم يكون Client Component عشان نقدر نستخدم React Context و Hooks
import { type ReactNode, createContext, useRef, useContext } from 'react'
import { useStore } from 'zustand'
import { type CartStore, createCartStore, defaultInitState, CartState } from '@/stores/cart-store'
// 1. تعريف نوع الـ API
// بنعرف TypeScript إن الـ Context ده هيشيل "Store Instance" مش State عادية
export type CartStoreApi = ReturnType<typeof createCartStore>
// 2. إنشاء الـ Context
// قيمته الابتدائية undefined لأننا لسه معملناش الـ Store
export const CartStoreContext = createContext<CartStoreApi | undefined>(undefined)
export interface CartStoreProviderProps {
children: ReactNode
// بنستقبل initialState اختياري (مهم جداً للـ Hydration لاحقاً)
initialState?: Partial<CartState>
}
export const CartStoreProvider = ({
children,
initialState
}: CartStoreProviderProps) => {
// 3. الخطوة السحرية: useRef
// إحنا محتاجين ننشئ الـ Store "مرة واحدة بس" لكل زيارة، ونضمن إنه ميتعملوش Reset مع كل Rerender للـ Provider.
const storeRef = useRef<CartStoreApi>(null)
// لو الـ Ref فاضي (أول مرة الـ Component يظهر)، ننشئ الـ Store
if (!storeRef.current) {
storeRef.current = createCartStore({ ...defaultInitState, ...initialState })
}
return (
<CartStoreContext.Provider value={storeRef.current}>
{children}
</CartStoreContext.Provider>
)
}
3.3 ليه استخدمنا useRef؟
دي نقطة فنية دقيقة: في React، أي متغير عادي جوه الـ Component بيتعاد تعريفه مع كل Re-render. لو كتبنا const store = createCartStore() مباشرة:
- الـ Provider يحصل له Render.
- Store جديد يتخلق.
- الـ Store القديم (بالداتا اللي فيه) يضيع!
- اليوزر يلاقي السلة فضيت فجأة.
الـ useRef بيشتغل زي “خزنة” بتحفظ القيمة جواها حتى لو الـ Component اتعمل له Render مليون مرة. بنعمل الـ Store مرة واحدة، ونحطه في الخزنة، ونستخدمه طول ما اليوزر فاتح الصفحة.
الاستهلاك الذكي - Custom Hooks (The Consumers)
دلوقتي عندنا الـ Context جاهز، بس استخدامه “خام” كده مش مريح:
- لازم كل مرة نعمل
useContext. - لازم نتأكد إننا جوه الـ Provider (Handle undefined).
- الأهم:
useContextلوحده مش كفاية عشان يخلي الـ Component يعمل Re-render لما جزء معين من الـ Store يتغير.
عشان كده هنعمل Custom Hook يحل المشاكل دي كلها.
4.1 الـ Hook السحري useCartStore
هنضيف الكود ده في نفس ملف src/providers/cart-store-provider.tsx (أو في ملف منفصل لو تحب).
// src/providers/cart-store-provider.tsx (تابع)
// بنعمل Hook يقبل Selector (دالة بتختار حتة معينة من الـ Store)
export const useCartStore = <T,>(
selector: (store: CartStore) => T,
): T => {
const cartStoreContext = useContext(CartStoreContext)
// بنضمن إننا بنستخدم الـ Hook جوه الـ Provider
if (!cartStoreContext) {
throw new Error(`useCartStore must be used within CartStoreProvider`)
}
// هنا السحر كله: useStore دي جاية من 'zustand'
// وظيفتها: تاخد الـ Store Instance + الـ Selector
// وتتأكد إن الـ Component ميعملش Rerender غير لما نتيجة الـ Selector تتغير بس!
return useStore(cartStoreContext, selector)
}
4.2 إزاي نستخدمه؟ (The Right Way)
القاعدة الذهبية في Zustand: اختار اللي محتاجه بس! (Atomic Selectors).
'use client'
import { useCartStore } from '@/providers/cart-store-provider'
export const CartBadge = () => {
// ✅ صح جداً: اخترنا رقم واحد بس
// الـ Component ده هيفضل ثابت ومش هيعمل Render حتى لو ضفت منتجات أو غيرت الأسماء،
// إلا لو عدد العناصر (length) اتغير.
const itemCount = useCartStore((state) => state.items.length)
return (
<div className="badge">
سلة التسوق ({itemCount})
</div>
)
}
4.3 أخطاء شائعة (Common Mistakes)
خلي بالك من الفخ ده، لأنه بيضيع كل مجهودك في الـ Performance:
// ❌ غلطة كارثية
const { items } = useCartStore((state) => state)
ليه غلط؟ لأنك هنا اخترت الـ State كلها (state => state). معنى كده إنك بتقول لـ React: “لو أي فتفوتة اتغيرت في الـ Store (حتى لو isOpen اللي أنا مش بستخدمه)، اعمل ريندر للكومبوننت ده!” ده بيحول التطبيق بتاعك لـ بطة بلدي في الأداء. دايماً حدد اللي عايزه بدقة.
معضلة الـ Hydration (The Hydration Dilemma)
دي المشكلة اللي بتخلي مطورين كتير يكرهوا حياتهم مع Next.js. تعال نفهمها بمثال بسيط.
5.1 يعني إيه Hydration Mismatch؟
تخيل إن السيرفر والعميل (Client) رسامين:
- السيرفر (الرسام الأول): رسم لوحة فيها “سلة التسوق: 5 منتجات” (لأنه قرأ من الداتابيز إن المستخدم عنده 5 منتجات)، وبعت اللوحة دي (HTML) للمتصفح.
- العميل (الرسام الثاني): استلم اللوحة، بس عشان يبدأ يشتغل (React Hydration)، لازم يرسم نفس اللوحة تاني عنده في الذاكرة.
- المشكلة: العميل معندوش داتابيز، والـ Store بتاعه لسه مخلوق حالا (بـ
defaultInitStateفاضي). فالعميل رسم “سلة التسوق: 0 منتجات”. - الخناقة: React بصت لقت السيرفر باعت “5” والعميل بيقول “0”. هنا بيحصل الـ Text content does not match server-rendered HTML، والشاشة بتعمل وميض (Flicker) وتتحول لـ 0.
5.2 الحل: توحيد المعلومات (Syncing Initial State)
عشان نحل المشكلة، لازم السيرفر وهو بيبعت اللوحة، يبعت معاها “ورقة غش” (Initial Data) يقول فيها للعميل: “ابدا رسمتك بـ 5 منتجات، مش 0”.
ده بيحصل عن طريق تمرير البيانات للـ Provider اللي عملناه.
5.3 التطبيق العملي في Server Component
في صفحة page.tsx (اللي هي Server Component)، نقدر نكلم الداتابيز مباشرة، وناخد البيانات نديها للـ Provider.
// src/app/page.tsx
import { CartStoreProvider } from '@/providers/cart-store-provider'
import { ProductGrid } from '@/components/product-grid'
import { Header } from '@/components/header'
import { db } from '@/lib/db' // فرضاً عندنا داتابيز
export default async function Home() {
// 1. السيرفر بيجيب البيانات الحقيقية
// (Server-side Data Fetching)
const userCart = await db.cart.findFirst({ where: { userId: '123' } })
// 2. بنجهز الـ Initial State
const initialCartState = {
items: userCart ? userCart.items : [],
isOpen: false
}
// 3. بنمرر البيانات للـ Provider
// الـ Provider هيستخدم البيانات دي عشان ينشئ الـ Store في الـ Client
return (
<CartStoreProvider initialState={initialCartState}>
<Header />
<main>
<ProductGrid />
</main>
</CartStoreProvider>
)
}
بمجرد ما تعمل كده:
- السيرفر هيعمل Render بـ 5 منتجات.
- الـ HTML هيوصل للمتصفح فيه 5 منتجات.
- الـ Client Store هيتخلق وجواه 5 منتجات (لأننا مررنا
initialStateللـcreateCartStoreفي الـ Provider). - النتيجة: تطابق تام (Perfect Hydration)
Zustand vs Redux Toolkit (أيهما أفضل؟)
ده السؤال الأبدي. Redux Toolkit (RTK) هو العملاق المشهور، و Zustand هو المنافس الرشيق. ليه ممكن تختار Zustand وتتعب نفسك في تعلم أداة جديدة؟
الجدول ده بيلخص الفروقات الجوهرية بينهم في بيئة Next.js:
| وجه المقارنة | Redux Toolkit (RTK) | Zustand |
|---|---|---|
| النموذج الذهني (Mental Model) | غير مباشر (Indirect): لازم تبعت Dispatch لـ Action، والـ Action يروح لـ Reducer، وبعدين الـ State تتغير. | مباشر (Direct): عندك دالة، بتناديها، الـ State تتغير. شكراً. |
| كمية الكود (Boilerplate) | كثيرة: محتاج Slice, Store, Provider, Types, useAppDispatch, useAppSelector. | قليلة جداً: بتعرف الـ Store في دالة واحدة، وتستخدمه بـ Hook واحد. |
| طريقة التحديث (Updates) | بتستخدم Immer داخلياً (Mutable syntax). | بتستخدم Immutable updates (أو Immer لو حبيت كـ Middleware). |
| Next.js SSR | معقدة: بتحتاج next-redux-wrapper وقصة كبيرة عشان تعمل Hydration صح. | سهلة: بتمشي على نمط الـ Factory Pattern اللي شرحناه، والدنيا بتمشي سلاسة. |
| التعلم (Learning Curve) | عالي: لازم تفهم Flux Architecture كويس. | منخفض: لو عارف JavaScript و React Hooks، أنت عارف Zustand. |
مقارنة بالكود (Code Comparison)
تعال نشوف الفرق في عمل Counter بسيط:
1. Redux Toolkit
// 1. Create Slice
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1 }
}
})
// 2. Configure Store
const store = configureStore({ reducer: counterSlice.reducer })
// 3. Create Typed Hooks (MANDATORY in TS)
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
// 4. Usage in Component
const count = useAppSelector(state => state.counter.value)
const dispatch = useAppDispatch()
// dispatching an action object!
<button onClick={() => dispatch(counterSlice.actions.increment())}>
2. Zustand
// 1. Create Store (One Step)
const createCounterStore = () => createStore((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 }))
}))
// 2. Usage in Component
const count = useStore(state => state.count)
const increment = useStore(state => state.increment)
// Just calling a function!
<button onClick={increment}>
الخلاصة
- لو مشروعك Enterprise ضخم جداً وفيه تيم كبير متعود على Redux، خليك مع RTK.
- لو مشروعك Start-up أو تطبيق SaaS وعايز سرعة في التطوير وأداء عالي وكود نضيف، Zustand هو الخيار الأمثل ليك.
Middleware & Compatibility (إمكانيات متقدمة)
Zustand مش بس مكتبة لتخزين الـ State، دي بيئة كاملة (Ecosystem) مليانة Middlewares قوية. تعال نشوف أهم اتنين بنحتاجهم دايماً.
7.1 Immer Middleware (السهولة في التحديث)
لو أنت جاي من خلفية Redux Toolkit، أكيد وحشك كتابة state.count++ بدل return { ...state, count: state.count + 1 }. خبر حلو: Zustand بتدعم Immer عشان تكتب Mutable Code براحتك، وهي بتتصرف في الـ Immutability وراء الكواليس.
import { immer } from 'zustand/middleware/immer'
export const createCartStore = (initState = defaultInitState) => {
return createStore<CartStore>()(
immer((set) => ({
...initState,
addItem: (product) => set((state) => {
// مش محتاج Spread Operators معقدة هنا!
const item = state.items.find(i => i.id === product.id)
if (item) {
item.quantity += 1 // عدل براحتك Direct Mutation
} else {
state.items.push({ ...product, quantity: 1 })
}
}),
}))
)
}
ملحوظة : لازم تعدل نوع الـ Store عشان TypeScript يفهم إنك بتستخدم Immer، بس دي تفصيلة ممكن ترجعلها في الـ Documentation.
7.2 Persist Middleware (تخزين البيانات)
دي بقى “العقدة” المشهورة في Next.js. عايزين نخزن السلة في localStorage عشان لما اليوزر يعمل Refresh المنتجات متضعش. بس المشكلة إن localStorage مش موجودة على السيرفر، فبيحصل خطأ “Text content does not match” أو “Window is not defined”.
الحل السحري: skipHydration
import { persist, createJSONStorage } from 'zustand/middleware'
export const createCartStore = (initState = defaultInitState) => {
return createStore<CartStore>()(
persist(
(set, get) => ({
...initState,
// ... باقي الـ Actions
}),
{
name: 'cart-storage', // اسم الـ Key في localStorage
// بنختار الـ Storage Engine
// استخدمنا دالة عشان نتجنب خطأ "window is not defined" وقت الـ Build
storage: createJSONStorage(() => localStorage),
// دي أهم حتة!
// بنقول لـ Zustand: "متشغلش الـ Persist أول ما تبدأ، استنى لما أقولك"
// عشان نتجنب خناقة السيرفر والعميل (Hydration Mismatch)
skipHydration: true,
}
)
)
}
إزاي نعرض البيانات المخزنة؟
بما إننا عملنا skipHydration: true، الـ Store هيبدأ فاضي (أو بالـ Initial State بتاع السيرفر). لازم “نصحيه” يدوي في الـ Client.
بس الأسهل والأضمن، إننا نستخدم Custom Hook يتأكد إننا في المتصفح قبل ما نعرض أي حاجة جاية من الـ LocalStorage.
// useIsMounted.ts
const useIsMounted = () => {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => setIsMounted(true), [])
return isMounted
}
// في الكومبوننت:
const CartTotal = () => {
const total = useCartStore(s => s.total)
const isMounted = useIsMounted()
// لو لسه بنعمل Hydration، اعرض حاجة مؤقتة أو Skeleton
// عشان نتجنب الـ Flicker أو الـ Error
if (!isMounted) return <Skeleton />
return <span>{total} EGP</span>
}
Testing the Store (اختبار المتجر)
واحدة من أجمل مميزات نمط الـ Factory Pattern اللي استخدمناه هي سهولة الـ Testing. تخيل لو كنا بنستخدم Singleton؟ كان زماننا بنعاني عشان “ننظف” الـ State بعد كل اختبار (Teardown).
بما إن createCartStore هي مجرد دالة بترجع Store جديد، الاختبارات بقت Direct ومفصولة تماماً عن بعضها (Isolated).
مثال باستخدام Vitest
مش محتاجين Mocking معقد. هننادي الدالة، ونتأكد إن الـ Logic شغال.
// src/stores/__tests__/cart-store.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { createCartStore } from '../cart-store'
describe('Cart Store', () => {
let store: ReturnType<typeof createCartStore>
beforeEach(() => {
// السحر هنا!
// بناخد نسخة "نضيفة" وجديدة تماماً قبل كل اختبار
// مفيش أي خوف إن اختبار يأثر على التاني
store = createCartStore()
})
it('should start empty', () => {
expect(store.getState().items).toHaveLength(0)
})
it('should add items correctly', () => {
const product = { id: '1', name: 'MacBook Pro', price: 2000 }
// Action
store.getState().addItem(product)
// Assertion
const items = store.getState().items
expect(items).toHaveLength(1)
expect(items[0]).toEqual({ ...product, quantity: 1 })
})
it('should increment quantity if item exists', () => {
const product = { id: '1', name: 'MacBook Pro', price: 2000 }
store.getState().addItem(product)
store.getState().addItem(product) // Add again
const items = store.getState().items
expect(items[0].quantity).toBe(2)
})
})
ليه ده أفضل من الـ Hook Testing؟
لاحظ إننا بنختبر الـ Store Logic (الـ Vanilla JS) من غير ما نحتاج نـ Render Components أو نستخدم renderHook من testing-library. ده بيخلي الاختبارات أسرع بكتير وأقل عرضة للمشاكل.
Performance Optimization (فن الأداء العالي)
أهم ميزة في Zustand هي إنها بتخليك تتحكم في الـ Re-renders بدقة الجراح. بس عشان توصل للمستوى ده، لازم تفهم إزاي React بتقارن الأشياء.
9.1 معضلة “الهوية” (The Identity Crisis)
تخيل إنك بتطلب من جرسون في مطعم (Zustand): “عايز طبق فيه رز وفراخ”. الجرسون راح المطبخ، وجابلك طبق جديد فيه رز وفراخ. لو طلبت منه تاني نفس الطلب، هيجيبلك طبق جديد (حتى لو فيه نفس الأكل بالظبط).
في لغة JavaScript:
{ a: 1 } === { a: 1 } // false 😱
الطبقين شبه بعض، بس ده طبق وده طبق تاني خالص (Different Reference).
المشكلة في الكود:
// ❌ النمط ده بيقتل الأداء
const { isOpen, items } = useCartStore(state => ({
isOpen: state.isOpen,
items: state.items
}))
كل مرة الـ Store بيتغير (حتى لو حاجة تافهة زي userName اتغيرت)، الدالة دي هترجع Object جديد. React هتشوف الـ Object الجديد، وتصرخ: “الحق! البيانات اتغيرت!” وتعمل Re-render للكومبوننت، حتى لو isOpen و items هما هما ماتغيروش!
9.2 الحل الأول: useShallow (النظرة الذكية)
هنا بنقول للجرسون (React): “بص، مش مهم الطبق نفسه، بص على اللي جوه الطبق. لو الرز هو هو، والفراخ هي هي، ماتجبش طبق جديد.”
import { useShallow } from 'zustand/react/shallow'
const { isOpen, items } = useCartStore(
useShallow(state => ({
isOpen: state.isOpen,
items: state.items
}))
)
useShallow بتعمل مقارنة سطحية (Shallow Comparison). بتشوف المفاتيح والقيم اللي جوه الـ Object. لو هي هي، بتمنع الـ Re-render. دي حركة ذكية جداً لو مضطر تختار أكتر من حاجة في نفس الوقت.
9.3 الحل الذهبي: Atomic Selectors (التخصص)
الأحسن من إنك تجيب طبق مشكل، إنك تطلب كل صنف لوحده. ده اللي بنسميه Atomic Selectors.
// ✅ الأداء المثالي
const isOpen = useCartStore(s => s.isOpen)
const items = useCartStore(s => s.items)
ليه ده أحسن؟ لأننا هنا بنرجع قيم بدائية (Primitives) زي Boolean أو Reference للـ Army (في حالة الـ items). Zustand ذكية كفاية إنها تقارن القيم دي بـ ===.
- هل
true === true؟ نعم (مفيش Re-render). - هل
itemsArray === itemsArray؟ نعم (مفيش Re-render).
القاعدة: جزّأ طلباتك، ترتاح وتريح الـ Browser معاك.
سيناريوهات متقدمة (Advanced Scenarios)
10.1 التحديث المتفائل (Optimistic Updates)
عارف لما تبعت رسالة واتساب وتظهر “علامة صح” فوراً قبل ما النت يبعتها فعلاً؟ ده اسمه Optimistic UI. إحنا بنفترض “تفاؤلاً” إن العملية هتنجح، ونحدث الـ UI فوراً عشان اليوزر يحس إن التطبيق طيارة.
مع Zustand و Server Actions، السيناريو بيبقى كالتالي:
// actions.ts (Server-Side)
'use server'
export async function updateCartInDb(cartData: any) {
await db.cart.update(...) // دي عملية بتاخد وقت
return await db.cart.get(...) // بنرجع النسخة الحقيقية من الداتابيز
}
// CartComponent.tsx (Client-Side)
'use client'
export function AddButton({ product }) {
// بنجيب دوال التعديل من الـ Store
const addItem = useCartStore(s => s.addItem)
const updateItems = useCartStore(s => s.updateItems)
const handleClick = async () => {
// 1. التحديث اللحظي (Optimistic Update)
// ضيف المنتج فوراً قدام اليوزر
addItem(product)
try {
// 2. كلم السيرفر في الخلفية
const freshData = await updateCartInDb(product)
// 3. التزامن (Reconciliation)
// لما السيرفر يرد، حدث الـ Store بالبيانات الحقيقية (عشان نضمن إن الأسعار والكميات صح)
updateItems(freshData.items)
} catch (error) {
// 4. التراجع (Rollback)
// لو حصل إيرور، لازم نرجع الـ Store زي ما كان ونعرف اليوزر
toast.error("فشل إضافة المنتج")
// logic to undo changes...
}
}
}
10.2 فن “التصفير” (Resetting the Store)
لما اليوزر يعمل Logout، لازم تمسح كل بياناته من الـ Browser Memory. لو معملتش كده، واليوزر التاني عمل Login من نفس الجهاز، ممكن يشوف سلة اليوزر اللي قبله! (كارثة أمنية).
الحل بسيط، ضيف reset Action في الـ Store بتاعك:
// stores/cart-store.ts
export const createCartStore = (initState) => createStore((set) => ({
...initState,
// ... actions
// القنبلة: زرار التدمير الذاتي 💣
reset: () => set(defaultInitState)
}))
واستخدمه في زرار الخروج:
const logout = () => {
useCartStore.getState().reset() // امسح كل حاجة
signOut() // كلم NextAuth
}
تنظيم الملفات للمشاريع الكبيرة (Enterprise Folder Structure)
في المشاريع الحقيقية (Real-world Applications) أو الـ Monorepos، “التنظيم” هو اللي بيفرق بين مشروع ناجح ومشروع “سباجيتي”. عشان كده، بننصح بفصل المسؤوليات (Separation of Concerns) بشكل صارم:
src/
├── app/
│ ├── layout.tsx <-- (Root) هنا بنحط الـ Providers
│ └── page.tsx <-- (Consumer) بيستخدم الـ Components
├── providers/
│ └── cart-store-provider.tsx <-- (The Bridge) كود الـ React Context
├── stores/
│ ├── cart-store.ts <-- (The Brain) Vanilla Typescript Logic (No React)
│ ├── auth-store.ts
│ └── slices/ <-- (Optional) لو الـ Store كبر، قسمه لـ Slices
│ ├── user-slice.ts
│ └── checkout-slice.ts
├── hooks/
│ └── use-cart-store.ts <-- (The Interface) هنا بنحط الـ Selectors والـ Custom Hooks
├── components/
│ ├── cart/
│ │ ├── cart-list.tsx <-- (Presentation) بيعرض الداتا
│ │ └── add-button.tsx
└── actions/
└── cart-actions.ts <-- (Server) Server Actions للداتابيز
ليه التقسيمة دي؟
- stores: خالصة تماماً (Pure TS). تقدر تعملها Test بسهولة من غير React.
- providers: ده المكان الوحيد اللي بيعرف إننا في Next.js أو React.
- hooks: بتفصل طريقة استدعاء الـ Store عن الـ Store نفسه. لو قررت تغير Zustand لأي مكتبة تانية، هتغير الـ Hooks بس، والـ Components مش هتحس بحاجة.
- components: “أغبياء” (Dumb Components). بياخدوا داتا ويعرضوها، ميعرفوش الداتا جاية منين.
قاموس المصطلحات (Glossary)
دي ترجمة “بالمصري” للمصطلحات اللي وجعنا دماغك بيها طول المقال:
مفاهيم الـ Rendering
- Client-Side Rendering (CSR): الطريقة العادية بتاعت زمان (زي Create React App). المتصفح بيفتح صفحة بيضاء، وبعدين يحمل ملف JavaScript تقيل هو اللي يربي الصفحة ويرسم كل حاجة.
- Server-Side Rendering (SSR): السيرفر هو “الشيف” اللي بيطبخ الصفحة (HTML) ويبعتها جاهزة للمتصفح. اليوزر يشوف المحتوى فوراً، بس لازم يستنى شوية لحد ما الـ JS يشتغل (Hydration) عشان يقدر يتفاعل مع الصفحة.
- React Server Components (RSC): الباشا الجديد في المدينة. دي Components بتشتغل على السيرفر بس، وليها وصول مباشر للداتابيز، والأحلى إن كودها مش بيتبعت للـ Client أصلاً (يعني Zero Bundle Size).
- Hydration: عملية “بث الروح” في الـ HTML اللي جاي ميت من السيرفر. React بتيجي توصل الأسلاك (Event Listeners زي
onClick) عشان الزراير تشتغل والموقع يبقى حي. - Hydration Mismatch: الخناقة اللي بتحصل لما السيرفر يرسم حاجة، والبراوزر لما ييجي يشتغل يلاقي حاجة تانية (زي مثلاً اختلاف التوقيت أو أرقام عشوائية). React بتزعل وتطلع Warning.
أنماط التصميم (Design Patterns)
- Singleton Pattern: إننا نعمل نسخة واحدة بس من الـ Store ونخلي التطبيق كله يشاركها. ده حلو في الـ Client العادي، بس “كارثة” في الـ SSR لأنه بيخلي داتا اليوزر ده تدخل على داتا اليوزر ده (Data Leak).
- Factory Pattern: بدل ما نعمل Store واحد، بنعمل “دالة” (Factory) بتصنع Store جديد لكل Request بييجي للسيرفر. ده الحل السحري اللي بيضمن إن كل يوزر في حاله.
- Dependency Injection: بدل ما الكومبوننت يروح ينادي الـ Store بنفسه (
import)، إحنا بنباصي الـ Store للكومبوننت (غالباً عن طريق Context). حركة شيك بتسهل الـ Testing وبتفصل المسؤوليات. - Flux Pattern: طريقة تنظيم البيانات اللي Redux و Zustand شغالين بيها. البيانات بتمشي في اتجاه واحد:
Actionيغير الـStore، والـStoreيغير الـView. مفيش لف ودوران.
مصطلحات الأداء (Performance)
- Boilerplate: كود الروتين الممل اللي لازم تكتبه عشان تشغل المكتبة، بس هو نفسه مش بيعمل حاجة مفيدة (زي طقوس Redux القديمة). Zustand جاية تقضي على الموضوع ده.
- Prop Drilling: لما تقعد تباصي داتا من الجد للأب للابن للحفيد، واللي في النص دول أصلاً مش محتاجين الداتا دي. حلها إننا نستخدم Store أو Context.
- Tree Shaking: ميزة في الـ Bundlers (زي Webpack) بتمسك شجرة الكود وتهزها، عشان توقع الورق الميت (الكود اللي مش بنستخدمه) وميدخلش في الملف النهائي.
- Strict Equality (
===): مقارنة “العنوان” مش “المحتوى”. هل الطبق ده هو هو نفس الطبق اللي في المطبخ؟ (Reference Equality). - Shallow Comparison: مقارنة “المحتوى” سطحياً. هل الرز اللي في الطبق ده نفس الرز اللي في الطبق ده؟ (بنستخدمها مع
useShallowعشان نوفر Render). - Atomic Selectors: إنك تختار “الفتفوتة” اللي عايزها بس من الـ Store. دي أحسن طريقة عشان تضمن إن الكومبوننت ميعملش Re-render عمال على بطال.
تجربة المستخدم (UX)
- Optimistic UI: حركة “فهلوة” مفيدة. بنحدث الشاشة فوراً لليوزر كأن العملية نجحت، عقبال ما السيرفر يرد علينا. (زي علامة الصح في الواتساب قبل ما الرسالة توصل فعلاً).
الأسئلة الشائعة (FAQ)
س: هل ينفع استخدم Zustand مع Server Actions؟ ج: بص، Server Action ده كود بيشتغل في السيرفر بس، و Zustand عايشة في المتصفح (Client) بس. فمينفعش تنادي useStore جوه السرڤر أكشن. الحل؟ خد الداتا من الـ Store وباصيها كـ Parameter للـ Action، ولما الـ Action يخلص، خد النتيجة ورجعها للـ Store (زي ما عملنا في مثال الـ Optimistic Updates).
س: أكتب الـ Logic فين؟ في الـ Store ولا في الكومبوننت؟ ج: قاعدة “المعدة الفاضية”: خلي الكومبوننت “غبي” (Dumb) ومجرد عرض بس. أي Logic بيغير في الـ State (زي حساب التوتال، فلترة المنتجات، الخ) ارميه جوه الـ Store. ده هيخلي الكود بتاعك نظيف وسهل تعملّه Test.
س: هل Zustand أخف من Redux؟ ج: يوه! فرق السما والأرض. Zustand حجمها حوالي 1.1kB (Gzipped)، في حين Redux Toolkit حجمها أضعاف الرقم ده. غير إن Zustand مفيهاش الـ Boilerplate والتعقيدات بتاعة Redux، فالمتصفح بيتنفس وهو بيشغلها.
س: هل ينفع استخدم أكثر من Store؟ (Multiple Stores) ج: طبعاً! ودي ميزة قوية في Zustand. ممكن تعمل Store للـ Auth، و Store للـ Cart، و Store للـ Settings. ده أحسن بكتير من إنك تحشر كل حاجة في Store عملاق واحد زي ما كنا بنعمل في Redux.
س: هل الـ Context Wrapper ده بيأثر على الأداء؟ ج: لا خالص. الـ Context هنا شغال “مواصلات” بس (Transport Layer). هو بينقل الـ Reference بتاع الـ Store، ومش بيتغير أبداً (Stable Reference). التغيير الحقيقي بيحصل جوه Zustand نفسها، وهي ذكية كفاية إنها متعملش Re-render غير للكومبوننت اللي محتاج التغيير بس.
س: هل ينفع استخدم Zustand مع Pages Router القديم؟ ج: أيوة، وهيكون أسهل كمان لأنك مش هتحتاج قصة الـ Provider ولا الـ Factory Pattern دي كلها، لأن الـ Pages Router معندوش مشكلة الـ Data Leak بين الطلبات زي الـ App Router. بس لو بتبدأ مشروع جديد، خليك في App Router عشان هو المستقبل.
ملحق الكود الكامل (Source Code Appendix)
للاختصار، ده الكود الكامل جاهز للنسخ واللصق.
src/stores/cart-store.ts
import { createStore } from 'zustand/vanilla'
import { persist, createJSONStorage } from 'zustand/middleware'
export type CartState = {
items: any[]
isOpen: boolean
}
export type CartActions = {
addItem: (item: any) => void
removeItem: (id: string) => void
toggle: () => void
}
export type CartStore = CartState & CartActions
export const defaultInitState: CartState = { items: [], isOpen: false }
export const createCartStore = (initState: CartState = defaultInitState) => {
return createStore<CartStore>()(
persist(
(set) => ({
...initState,
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({ items: s.items.filter(i => i.id !== id) })),
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
}),
{
name: 'cart-storage',
storage: createJSONStorage(() => localStorage),
skipHydration: true,
}
)
)
}
src/providers/cart-provider.tsx
'use client'
import { createContext, useContext, useRef, ReactNode } from 'react'
import { useStore } from 'zustand'
import { createCartStore, CartStore } from '@/stores/cart-store'
export const CartContext = createContext<ReturnType<typeof createCartStore> | undefined>(undefined)
export const CartProvider = ({ children, initialState }: { children: ReactNode, initialState?: any }) => {
const storeRef = useRef<ReturnType<typeof createCartStore>>(null)
if (!storeRef.current) {
storeRef.current = createCartStore(initialState)
}
return <CartContext.Provider value={storeRef.current}>{children}</CartContext.Provider>
}
export const useCartStore = <T,>(selector: (store: CartStore) => T): T => {
const context = useContext(CartContext)
if (!context) throw new Error('Missing CartProvider')
return useStore(context, selector)
}
الخلاصة (Conclusion)
دمج Zustand مع Next.js App Router ممكن يبان “تكدير” في الأول بسبب كمية الكود (Boilerplate) اللي بنكتبه (Store + Context + Provider + Hooks). بس صدقني، ده “استثمار” هتشوف عائده لما المشروع يكبر.
أنت دلوقتي معاك الـ Architecture اللي بتستخدمها أكبر التيمات التقنية عشان تبني تطبيقات Scalable ومحترمة. ابدأ طبق الكلام ده في مشاريعك، وانسى كوابيس الـ Hydration Errors للأبد.
تم بحمد الله.
