Zustand State Management: Patterns for Complex Apps

Master Zustand state management for React apps. Covers slices, middleware, persistence, devtools, TypeScript patterns, and integration with TanStack Query for complex application state.

E
ECOSIRE Research and Development Team
|19 مارس 202610 دقائق قراءة2.2k كلمات|

إدارة حالة Zustand: أنماط للتطبيقات المعقدة

تعمل أساسيات الحالة المضمنة في React (useState، useReducer، useContext) بشكل جيد لحالة واجهة المستخدم المحلية والمشتركة، لكنها تواجه احتكاكًا على نطاق واسع: يعيد موفرو السياق عرض الشجرة بأكملها عند كل تغيير في الحالة، ويتطلب useReducer نموذجًا مطولًا للإجراء المطول، ويصبح حفر الدعامات غير قابل للإدارة. يحل Redux هذه المشكلة ولكن على حساب نموذج معياري كبير وعبء ذهني كبير.

يحتل Zustand أرضية وسطية مثالية: 2.9 كيلوبايت، بدون مقدمي خدمات، بدون نموذج معياري، TypeScript-first، ومتوافق مع React DevTools. إنه يتفوق في حالة التطبيق من جانب العميل - عربات التسوق، وحالة الشريط الجانبي، وبيانات النماذج متعددة الخطوات، وتفضيلات واجهة المستخدم - بينما يتعامل TanStack Query مع حالة الخادم. يغطي هذا الدليل أنماط Zustand v5 لتطبيقات الإنتاج.

الوجبات الرئيسية

  • Zustand مخصص لحالة العميل فقط؛ استخدم TanStack Query لحالة الخادم - لا تتداخل مع مسؤولياتهم
  • لا حاجة إلى برنامج تضمين موفر - المتجر فردي على مستوى الوحدة، ويمكن الوصول إليه من أي مكان
  • استخدم نمط الشرائح للمتاجر الكبيرة: أنشئ منشئي شرائح متعددة في مكالمة create() واحدة
  • تتيح البرامج الوسيطة immer صياغة الطفرات المباشرة داخل الإجراءات (بدون عوامل انتشار)
  • persist برنامج وسيط مع localStorage أو sessionStorage لحالة الجلسات المشتركة
  • تتكامل البرامج الوسيطة devtools مع ملحق Redux DevTools لتصحيح أخطاء السفر عبر الزمن
  • تمنع المحددات ذات useShallow عمليات إعادة العرض غير الضرورية من الكائنات المتساوية الضحلة
  • لا تقم أبدًا بتخزين بيانات الخادم في Zustand — يصبح الإبطال مشكلتك

المتجر الأساسي

// src/lib/stores/ui.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface UiState {
  // State
  sidebarOpen: boolean;
  theme: 'light' | 'dark' | 'system';
  commandPaletteOpen: boolean;
  activeModal: string | null;

  // Actions
  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;
  setTheme: (theme: UiState['theme']) => void;
  openModal: (modalId: string) => void;
  closeModal: () => void;
  toggleCommandPalette: () => void;
}

export const useUiStore = create<UiState>()(
  devtools(
    (set) => ({
      sidebarOpen: true,
      theme: 'system',
      commandPaletteOpen: false,
      activeModal: null,

      toggleSidebar: () =>
        set((state) => ({ sidebarOpen: !state.sidebarOpen }), false, 'toggleSidebar'),

      setSidebarOpen: (open) =>
        set({ sidebarOpen: open }, false, 'setSidebarOpen'),

      setTheme: (theme) =>
        set({ theme }, false, 'setTheme'),

      openModal: (modalId) =>
        set({ activeModal: modalId }, false, 'openModal'),

      closeModal: () =>
        set({ activeModal: null }, false, 'closeModal'),

      toggleCommandPalette: () =>
        set(
          (state) => ({ commandPaletteOpen: !state.commandPaletteOpen }),
          false,
          'toggleCommandPalette'
        ),
    }),
    { name: 'ui-store' }
  )
);
// Usage in components — select only what you need
function Sidebar() {
  const isOpen = useUiStore((state) => state.sidebarOpen);
  const toggle = useUiStore((state) => state.toggleSidebar);

  return (
    <aside className={cn('sidebar', isOpen && 'sidebar--open')}>
      <button onClick={toggle}>Toggle</button>
    </aside>
  );
}

متجر سلة التسوق بإصرار

// src/lib/cart.ts
import { create } from 'zustand';
import { persist, createJSONStorage, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface CartItem {
  id: string;
  productId: string;
  name: string;
  price: number;
  quantity: number;
  licenseType: 'one-time' | 'subscription';
}

interface CartState {
  items: CartItem[];
  couponCode: string | null;
  couponDiscount: number;

  // Computed (derived state — use selectors, not stored values)
  // subtotal, total, itemCount — computed in selectors

  // Actions
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  applyCoupon: (code: string, discount: number) => void;
  removeCoupon: () => void;
  clearCart: () => void;
}

export const useCart = create<CartState>()(
  devtools(
    persist(
      immer((set) => ({
        items: [],
        couponCode: null,
        couponDiscount: 0,

        addItem: (newItem) =>
          set((state) => {
            const existing = state.items.find((i) => i.productId === newItem.productId);
            if (existing) {
              existing.quantity += 1; // Immer allows direct mutation
            } else {
              state.items.push({ ...newItem, quantity: 1 });
            }
          }),

        removeItem: (productId) =>
          set((state) => {
            state.items = state.items.filter((i) => i.productId !== productId);
          }),

        updateQuantity: (productId, quantity) =>
          set((state) => {
            if (quantity <= 0) {
              state.items = state.items.filter((i) => i.productId !== productId);
            } else {
              const item = state.items.find((i) => i.productId === productId);
              if (item) item.quantity = quantity;
            }
          }),

        applyCoupon: (code, discount) =>
          set((state) => {
            state.couponCode = code;
            state.couponDiscount = discount;
          }),

        removeCoupon: () =>
          set((state) => {
            state.couponCode = null;
            state.couponDiscount = 0;
          }),

        clearCart: () =>
          set((state) => {
            state.items = [];
            state.couponCode = null;
            state.couponDiscount = 0;
          }),
      })),
      {
        name: 'ecosire-cart',
        storage: createJSONStorage(() => localStorage),
        partialize: (state) => ({
          // Only persist items and coupon, not derived state
          items: state.items,
          couponCode: state.couponCode,
          couponDiscount: state.couponDiscount,
        }),
      }
    ),
    { name: 'cart-store' }
  )
);

// Selectors — computed from state, not stored
export const selectSubtotal = (state: CartState) =>
  state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

export const selectTotal = (state: CartState) => {
  const subtotal = selectSubtotal(state);
  return subtotal * (1 - state.couponDiscount);
};

export const selectItemCount = (state: CartState) =>
  state.items.reduce((sum, item) => sum + item.quantity, 0);

نمط الشريحة للمتاجر الكبيرة

بالنسبة للمخازن المعقدة، قم بتقسيمها إلى شرائح وقم بتأليف:

// src/lib/stores/slices/filters.slice.ts
import type { StateCreator } from 'zustand';

export interface FiltersSlice {
  search: string;
  status: string;
  page: number;
  sortField: string;
  sortDirection: 'asc' | 'desc';

  setSearch: (search: string) => void;
  setStatus: (status: string) => void;
  setPage: (page: number) => void;
  setSort: (field: string, direction: 'asc' | 'desc') => void;
  resetFilters: () => void;
}

const initialFilters = {
  search: '',
  status: 'all',
  page: 1,
  sortField: 'createdAt',
  sortDirection: 'desc' as const,
};

export const createFiltersSlice: StateCreator<FiltersSlice> = (set) => ({
  ...initialFilters,

  setSearch: (search) => set({ search, page: 1 }), // Reset page on search
  setStatus: (status) => set({ status, page: 1 }),
  setPage: (page) => set({ page }),
  setSort: (field, direction) => set({ sortField: field, sortDirection: direction }),
  resetFilters: () => set(initialFilters),
});
// src/lib/stores/slices/selection.slice.ts
import type { StateCreator } from 'zustand';

export interface SelectionSlice {
  selectedIds: Set<string>;
  selectAll: boolean;

  toggleSelection: (id: string) => void;
  selectAllItems: (ids: string[]) => void;
  clearSelection: () => void;
}

export const createSelectionSlice: StateCreator<SelectionSlice> = (set) => ({
  selectedIds: new Set(),
  selectAll: false,

  toggleSelection: (id) =>
    set((state) => {
      const next = new Set(state.selectedIds);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return { selectedIds: next, selectAll: false };
    }),

  selectAllItems: (ids) =>
    set({ selectedIds: new Set(ids), selectAll: true }),

  clearSelection: () =>
    set({ selectedIds: new Set(), selectAll: false }),
});
// src/lib/stores/contacts-table.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createFiltersSlice, type FiltersSlice } from './slices/filters.slice';
import { createSelectionSlice, type SelectionSlice } from './slices/selection.slice';

type ContactsTableStore = FiltersSlice & SelectionSlice;

export const useContactsTableStore = create<ContactsTableStore>()(
  devtools(
    (...args) => ({
      ...createFiltersSlice(...args),
      ...createSelectionSlice(...args),
    }),
    { name: 'contacts-table-store' }
  )
);

منع عمليات إعادة العرض غير الضرورية

يقوم Zustand بإعادة عرض المكون عندما تتغير القيمة المحددة (حسب مرجع الكائنات).

// PROBLEM: new object created on every render → infinite re-renders
const { search, page } = useContactsTableStore((state) => ({
  search: state.search,
  page: state.page,
}));

// SOLUTION 1: Select primitives individually
const search = useContactsTableStore((state) => state.search);
const page   = useContactsTableStore((state) => state.page);

// SOLUTION 2: Use useShallow for object selections
import { useShallow } from 'zustand/react/shallow';

const { search, page } = useContactsTableStore(
  useShallow((state) => ({ search: state.search, page: state.page }))
);

// SOLUTION 3: Select an action (actions never change reference)
const setSearch = useContactsTableStore((state) => state.setSearch);

استعلام Zustand + TanStack: مسح الفصل

النظام الرئيسي: Zustand لحالة العميل، وTanStack Query لحالة الخادم - لا يتداخلان أبدًا.

// WRONG: storing server data in Zustand
const useContactsStore = create(() => ({
  contacts: [],          // Server data in Zustand — invalidation nightmare
  fetchContacts: async () => { /* fetch and set */ },
}));

// CORRECT: TanStack Query for server data, Zustand for UI state
// TanStack Query
const { data: contacts } = useContacts({ page, limit, search });

// Zustand — only UI-specific state
const { search, page, setSearch, setPage } = useContactsTableStore();

يتفاعل مفتاح الاستعلام مع تغييرات حالة Zustand، لذا فإن التصفية/الفرز تعمل فقط:

function ContactsPage() {
  // UI state from Zustand
  const search = useContactsTableStore((s) => s.search);
  const page   = useContactsTableStore((s) => s.page);
  const status = useContactsTableStore((s) => s.status);

  // Server state from TanStack Query, driven by Zustand state
  const { data, isLoading } = useContacts({ search, page, status, limit: 20 });

  return <ContactsTable data={data?.data ?? []} loading={isLoading} />;
}

مخزن النماذج متعدد الخطوات

// src/lib/stores/onboarding.store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface OnboardingData {
  company: { name: string; size: string; industry: string };
  contact: { name: string; email: string; phone: string };
  requirements: { erp: string; modules: string[]; budget: string };
}

interface OnboardingStore {
  currentStep: number;
  completed: Set<number>;
  data: Partial<OnboardingData>;

  setStep: (step: number) => void;
  updateStepData: <K extends keyof OnboardingData>(
    step: K,
    data: OnboardingData[K]
  ) => void;
  markStepComplete: (step: number) => void;
  reset: () => void;
}

export const useOnboardingStore = create<OnboardingStore>()(
  persist(
    (set) => ({
      currentStep: 0,
      completed: new Set(),
      data: {},

      setStep: (step) => set({ currentStep: step }),

      updateStepData: (key, stepData) =>
        set((state) => ({
          data: { ...state.data, [key]: stepData },
        })),

      markStepComplete: (step) =>
        set((state) => ({
          completed: new Set([...state.completed, step]),
        })),

      reset: () => set({ currentStep: 0, completed: new Set(), data: {} }),
    }),
    {
      name: 'onboarding-progress',
      storage: createJSONStorage(() => sessionStorage),
      // Serialize Set properly
      partialize: (state) => ({
        ...state,
        completed: Array.from(state.completed),
      }),
      merge: (persisted: any, current) => ({
        ...current,
        ...persisted,
        completed: new Set(persisted.completed ?? []),
      }),
    }
  )
);

اختبار متاجر زوستاند

// src/lib/stores/__tests__/cart.store.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useCart, selectSubtotal, selectTotal } from '../cart';

describe('Cart Store', () => {
  beforeEach(() => {
    // Reset store state between tests
    useCart.setState({
      items: [],
      couponCode: null,
      couponDiscount: 0,
    });
  });

  it('adds items to the cart', () => {
    const { addItem } = useCart.getState();
    addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });

    const { items } = useCart.getState();
    expect(items).toHaveLength(1);
    expect(items[0].quantity).toBe(1);
  });

  it('increments quantity when same product added again', () => {
    const { addItem } = useCart.getState();
    addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
    addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });

    const { items } = useCart.getState();
    expect(items).toHaveLength(1);
    expect(items[0].quantity).toBe(2);
  });

  it('calculates subtotal correctly', () => {
    useCart.setState({
      items: [
        { id: '1', productId: 'p1', name: 'A', price: 100, quantity: 2, licenseType: 'one-time' },
        { id: '2', productId: 'p2', name: 'B', price: 50,  quantity: 1, licenseType: 'one-time' },
      ],
    });

    expect(selectSubtotal(useCart.getState())).toBe(250);
  });

  it('applies coupon discount', () => {
    useCart.setState({
      items: [{ id: '1', productId: 'p1', name: 'A', price: 100, quantity: 1, licenseType: 'one-time' }],
      couponCode: 'SAVE20',
      couponDiscount: 0.2,
    });

    expect(selectTotal(useCart.getState())).toBe(80);
  });
});

الأسئلة المتداولة

هل ما زلت بحاجة إلى واجهة برمجة تطبيقات السياق مع Zustand؟

نادرًا. يحل المفرد على مستوى الوحدة النمطية لـ Zustand محل معظم حالات استخدام واجهة برمجة تطبيقات السياق. حالة الاستخدام الرئيسية المتبقية للسياق هي حقن التبعية - تمرير مثيل متجر معين إلى شجرة فرعية (مفيد للاختبار أو عندما تحتاج إلى مثيلات متجر مستقلة متعددة). من أجل ذلك، توفر Zustand createStore() + useStore() التي تعمل مع سياق React لمثيل المتجر.

كيف يمكن مقارنة Zustand بمجموعة أدوات Redux؟

تحتوي مجموعة أدوات Redux على بنية أكثر (شرائح، ومخفضات، وإجراءات) وتكامل أفضل لـ DevTools لتصحيح أخطاء السفر عبر الزمن المعقدة. يحتوي Zustand على حزمة نموذجية أقل، وحزمة أصغر (2.9 كيلو بايت مقابل ~ 30 كيلو بايت لـ Redux + RTK)، ولا يتطلب موفرًا. بالنسبة لمعظم التطبيقات، يعتبر Zustand أبسط وكفى. اختر Redux Toolkit إذا كان لديك فريق كبير يستفيد من الأنماط المفروضة أو يحتاج إلى ميزات DevTools المتقدمة.

كيف يمكنني استخدام Zustand مع Next.js App Router SSR؟

متاجر Zustand عبارة عن وحدات فردية على مستوى الوحدة — وهي آمنة لمكونات العميل ولكن لا يمكن استخدامها في مكونات الخادم (لا توجد واجهات برمجة تطبيقات للمتصفح، ولا توجد نافذة). تهيئة المخازن في مكونات 'use client' فقط. لترطيب SSR للبيانات الأولية، استخدم HydrationBoundary الخاص بـ TanStack Query لبيانات الخادم. بالنسبة لحالة العميل فقط (عربة التسوق، تفضيلات واجهة المستخدم) التي تحتاج إلى الاستمرار عبر التنقل، استخدم persist الوسيطة مع localStorage.

ما هي البرمجيات الوسيطة immer ومتى يجب استخدامها؟

تقوم البرمجيات الوسيطة immer بتغليف وظيفة set الخاصة بـ Zustand مع Immer، مما يسمح لك بكتابة تعليمات برمجية متحورة في الإجراءات (state.items.push(item)) والتي تكون في الواقع غير قابلة للتغيير تحت الغطاء. استخدمه عندما يكون لديك تحديثات حالة متداخلة غير ملائمة مع بناء جملة الانتشار، مثل تحديث خاصية متداخلة بعمق أو العمل مع المصفوفات. إنها تضيف ما يقرب من 6 كيلو بايت إلى مجموعتك - وهو أمر يستحق ذلك بالنسبة للمتاجر المعقدة، وغير ضروري للمتاجر البسيطة.

كيف أتجنب عمليات الإغلاق التي لا معنى لها في إجراءات Zustand؟

يقوم Zustand بتمرير الحالة الحالية إلى رد الاتصال set، لذا استخدم دائمًا النموذج الوظيفي عند قراءة الحالة داخل الإجراء: set((state) => ({ count: state.count + 1 })) بدلاً من التقاط state.count من الإغلاق. بالنسبة للإجراءات غير المتزامنة التي تقرأ الحالة بعد await، اتصل بـ getState() عند نقطة الاستخدام بدلاً من التقاط الحالة في الإغلاق قبل الانتظار.


الخطوات التالية

إن الحجم الصغير لـ Zustand ونمط الشريحة القابل للتركيب يجعلها الأداة المناسبة لحالة العميل في تطبيقات React بأي حجم. مقترنًا بـ TanStack Query لحالة الخادم، لديك بنية إدارة حالة نظيفة وقابلة للتطوير دون أي عبء معياري.

تقوم ECOSIRE ببناء تطبيقات React باستخدام Zustand + TanStack Query باعتبارها مجموعة إدارة الحالة القياسية - التي تخدم لوحات الإدارة المعقدة وبوابات العملاء وتدفقات التجارة الإلكترونية. استكشف خدماتنا الهندسية للواجهة الأمامية لتتعرف على كيفية تصميم تطبيقات React القابلة للتطوير.

E

بقلم

ECOSIRE Research and Development Team

بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.

الدردشة على الواتساب