الكتابات/tutorial/2026/07
Tutorial14 يوليو 2026·28 دقيقة

XState v5 مع React وTypeScript: بناء آلات حالة لا تنكسر

تعلّم كيف تنمذج منطق الواجهة والعمليات غير المتزامنة المعقّدة باستخدام XState v5 في تطبيق Next.js. يغطي هذا الدرس العملي واجهة setup() والسياق والأحداث المُنمّطة، وممثّلي الوعود، والحُرّاس، وإعادة المحاولة والإلغاء، والممثّلين الأبناء، والحالة العامة عبر createActorContext، والحفظ، والاختبار.

كل مطوّر React كتب هذا الكود من قبل:

const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isRetrying, setIsRetrying] = useState(false)

أربعة متغيّرات منطقية تعني ستّ عشرة تركيبة ممكنة، وأربع منها فقط صالحة. ماذا يحدث حين تصبح قيمة isLoading وisSuccess صحيحة في آن واحد؟ لا شيء جيّد — ولا شيء في كودك يمنع ذلك أيضًا. هذه بالضبط فئة الأخطاء التي تُنتج زبائن يُخصم منهم مرّتين، ومؤشّرات تحميل عالقة، ونماذج تُرسَل مرّتين.

يحلّ XState v5 هذه المشكلة بجعل الحالات غير القانونية غير قابلة للتمثيل أصلًا. فبدل كيس من المتغيّرات المنطقية، تُصرّح بمجموعة منتهية من الحالات وبالانتقالات الدقيقة بينها. وإذا لم يكن الانتقال مُصرَّحًا به، فلا يمكن أن يحدث. تتجاهل الآلة الحدث ببساطة.

في هذا الدرس ستبني ميزة واقعية بحجم الإنتاج — مسار دفع متعدّد الخطوات مع دفع غير متزامن، وإعادة محاولة، وإلغاء، ورفع ملفات متوازٍ — باستخدام XState v5 وReact وTypeScript داخل تطبيق Next.js. وفي النهاية ستفهم الممثّلين، وواجهة setup()، واستدعاء الوعود، والحُرّاس، والتوليد، والحفظ، والاختبار.

المتطلّبات المسبقة

قبل البدء، تأكّد من توفّر:

  • Node.js 20 أو أحدث
  • معرفة عملية بـ خطّافات React والأنواع العامة في TypeScript
  • إلمام بـ async/await والوعود
  • محرّر أكواد (VS Code مع إضافة Stately خيار مثالي)
  • نحو 60 دقيقة من التركيز

لا تحتاج إلى خبرة سابقة بـ XState. وإن كنت قد استخدمت الإصدار الرابع، فاعلم أنّ الخامس إعادة تصميم جذرية: واجهات Machine() وwithContext() وservices القديمة اختفت.

ما الذي ستبنيه

آلة دفع تقوم بـ:

  1. جمع تفاصيل السلّة (حالة الخمول)
  2. التحقّق من صحّة السلّة قبل السماح بالإرسال (الحُرّاس)
  3. استدعاء واجهة دفع برمجية (ممثّل وعد)
  4. إعادة المحاولة حتى ثلاث مرّات عند فشل الشبكة مع تأخير أُسّي (انتقالات مؤجّلة)
  5. القابلية للإلغاء أثناء التنفيذ (تفكيك الممثّل)
  6. رفع المرفقات بالتوازي (ممثّلون أبناء مولَّدون)
  7. النجاة من إعادة تحميل الصفحة (الحفظ)

وخلال ذلك سترى لماذا تصبح الآلة — لا المكوّن — هي مصدر الحقيقة.

الخطوة 1: تجهيز المشروع

أنشئ تطبيق Next.js وأضف حزم XState:

npx create-next-app@latest xstate-checkout --typescript --app --tailwind
cd xstate-checkout
npm install xstate @xstate/react
npm install -D @statelyai/inspect

ثلاث حزم، ثلاث وظائف:

  • xstate — النواة المستقلّة عن أي إطار (الآلات، الممثّلون، المفسّرون)
  • @xstate/react — روابط React (useMachine, useSelector, createActorContext)
  • @statelyai/inspect — مصحّح بصري حيّ، للتطوير فقط

تأكّد من أنّ الإصدار هو الخامس (أي 5.x):

npm ls xstate

الخطوة 2: أوّل آلة لك مع setup()

أكبر تغيير في الإصدار الخامس هو دالّة setup(). فيها تسجّل الأنواع والممثّلين والإجراءات والحُرّاس والتأخيرات قبل تعريف الآلة. وكلّ ما يُسجَّل داخل setup() يصبح مُنمّطًا بالكامل داخل تعريف الآلة — لا خطوة typegen بعد اليوم، ولا ملفات مولَّدة.

أنشئ src/machines/checkout.machine.ts:

import { setup, assign } from 'xstate'
 
// البيانات التي تحملها الآلة عبر الحالات
interface CheckoutContext {
  cartTotal: number
  email: string
  orderId: string | null
  errorMessage: string | null
  retries: number
}
 
// كل حدث يمكن أن تستقبله الآلة
type CheckoutEvent =
  | { type: 'CART.UPDATE'; total: number }
  | { type: 'EMAIL.CHANGE'; email: string }
  | { type: 'SUBMIT' }
  | { type: 'CANCEL' }
  | { type: 'RETRY' }
  | { type: 'RESET' }
 
export const checkoutMachine = setup({
  types: {
    context: {} as CheckoutContext,
    events: {} as CheckoutEvent,
  },
}).createMachine({
  id: 'checkout',
  initial: 'editing',
  context: {
    cartTotal: 0,
    email: '',
    orderId: null,
    errorMessage: null,
    retries: 0,
  },
  states: {
    editing: {
      on: {
        'CART.UPDATE': {
          actions: assign({ cartTotal: ({ event }) => event.total }),
        },
        'EMAIL.CHANGE': {
          actions: assign({ email: ({ event }) => event.email }),
        },
        SUBMIT: { target: 'submitting' },
      },
    },
    submitting: {},
    success: { type: 'final' },
    failure: {},
  },
})

لاحظ شكل الإجراء: ({ context, event }) => value. في الإصدار الخامس تستقبل كلّ دالّة رجوع كائنًا واحدًا، لا وسائط موضعية. هذا التغيير وحده هو ما يجعل الواجهة كلّها قابلة للاستنتاج التلقائي للأنواع.

ولاحظ أيضًا ما ليس موجودًا: لا isLoading ولا isError. اسم الحالة هو مؤشّر التحميل. لا يمكن للآلة أن تكون في editing وsubmitting معًا — نظام الأنواع وزمن التشغيل كلاهما يمنع ذلك.

الخطوة 3: ربط الآلة بـ React

يبدأ الخطّاف useMachine ممثّلًا عند تركيب المكوّن ويوقفه عند إزالته. أنشئ src/components/Checkout.tsx:

'use client'
 
import { useMachine } from '@xstate/react'
import { checkoutMachine } from '@/machines/checkout.machine'
 
export function Checkout() {
  const [snapshot, send] = useMachine(checkoutMachine)
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        send({ type: 'SUBMIT' })
      }}
    >
      <input
        type="email"
        value={snapshot.context.email}
        onChange={(e) => send({ type: 'EMAIL.CHANGE', email: e.target.value })}
      />
 
      <button type="submit" disabled={!snapshot.can({ type: 'SUBMIT' })}>
        {snapshot.matches('submitting') ? 'جارٍ المعالجة…' : 'ادفع الآن'}
      </button>
 
      {snapshot.matches('failure') && <p role="alert">{snapshot.context.errorMessage}</p>}
    </form>
  )
}

تحمل دالّتان معظم العبء هنا.

تسأل snapshot.matches('submitting') سؤالًا عن الحالة الراهنة. ولأنّ أسماء الحالات جاءت من setup()، يُكملها TypeScript تلقائيًا ويرفض الأخطاء الإملائية وقت الترجمة.

أمّا snapshot.can(...) فهي التي تقضي على عائلة كاملة من الأخطاء. إنّها تسأل الآلة: لو أرسلتُ هذا الحدث الآن، هل سيحدث أي شيء؟ الآلة تعرف مسبقًا أنّ SUBMIT لا يُعالَج إلا في editing، فيُعطّل الزر نفسه تلقائيًا في كل الحالات الأخرى. لن تكتب disabled={isLoading || isSubmitting || !isValid} مرّة أخرى — ولن تنسى أحد تلك الشروط أبدًا.

الخطوة 4: العمليات غير المتزامنة عبر ممثّلي الوعود

في XState v5، كلّ شيء ممثّل: الآلات والوعود ودوال الرجوع والمُراقَبات. لاستدعاء واجهة الدفع، غلّفها بـ fromPromise وسجّلها في setup():

import { setup, assign, fromPromise } from 'xstate'
 
interface PaymentInput {
  email: string
  amount: number
}
 
interface PaymentResult {
  orderId: string
}
 
async function chargeCard(input: PaymentInput): Promise<PaymentResult> {
  const res = await fetch('/api/payments', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  })
 
  if (!res.ok) {
    throw new Error(`فشل الدفع بالحالة ${res.status}`)
  }
 
  return res.json()
}
 
export const checkoutMachine = setup({
  types: {
    context: {} as CheckoutContext,
    events: {} as CheckoutEvent,
  },
  actors: {
    // fromPromise تحوّل أي دالّة غير متزامنة إلى ممثّل
    chargeCard: fromPromise<PaymentResult, PaymentInput>(({ input }) => chargeCard(input)),
  },
}).createMachine({
  // ...
})

المعاملان العامّان هما نوع الخَرْج ثم نوع الدَّخْل، بهذا الترتيب. والآن استدعِ الممثّل من حالة submitting:

submitting: {
  invoke: {
    src: 'chargeCard',
    // input يُسقِط سياق الآلة على دخل الممثّل
    input: ({ context }) => ({
      email: context.email,
      amount: context.cartTotal,
    }),
    onDone: {
      target: 'success',
      actions: assign({
        orderId: ({ event }) => event.output.orderId,
      }),
    },
    onError: {
      target: 'failure',
      actions: assign({
        errorMessage: ({ event }) => (event.error as Error).message,
      }),
    },
  },
  on: {
    CANCEL: { target: 'editing' },
  },
},

اقرأ هذا بتمعّن، فهو قلب XState. يقول invoke: ما دامت هذه الحالة نشطة، شغّل هذا الممثّل. ودالّة input هي الجسر الوحيد بين سياق الآلة والممثّل — لا يستطيع الممثّل الوصول إلى سياقك، وهذا ما يبقيه قابلًا للاختبار باستقلال.

عند النجاح تصل قيمة الوعد في event.output، وعند الفشل يصل الخطأ في event.error. ويعرف TypeScript كلا النوعين من الأنواع العامة التي مرّرتها لـ fromPromise.

وماذا عن معالج CANCEL؟ حين تغادر الآلة حالة submitting، يوقف XState الممثّل المُستدعى تلقائيًا. لا تنظيف في useEffect تكتبه، ولا AbortController تتذكّره، ولا تحذير "المكوّن أُزيل". مغادرة الحالة تُفكّك كلّ ما تملكه تلك الحالة. هذه الخاصيّة تحديدًا هي ما يجعل الآلات أكثر أمانًا بكثير من الخطّافات المرتجلة: التنظيف بنيوي، لا شيء متروك لذاكرتك.

الخطوة 5: الحُرّاس ورفض الانتقالات غير الصالحة

حاليًا يستطيع المستخدم إرسال سلّة فارغة. الحُرّاس دوالّ تحقّق نقيّة تقرّر ما إذا كان الانتقال مسموحًا. سجّلها في setup():

export const checkoutMachine = setup({
  types: { /* ... */ },
  actors: { /* ... */ },
  guards: {
    isCartValid: ({ context }) =>
      context.cartTotal > 0 && context.email.includes('@'),
    canRetry: ({ context }) => context.retries < 3,
  },
}).createMachine({
  // ...
})

ثم اربط الحارس بالانتقال:

editing: {
  on: {
    SUBMIT: {
      target: 'submitting',
      guard: 'isCartValid',
    },
  },
},

المكسب فوري: صارت snapshot.can({ type: 'SUBMIT' }) تُقيّم الحارس أيضًا. فيُعطَّل زر الدفع تلقائيًا عند سلّة غير صالحة، دون سطر واحد إضافي في المكوّن. منطق التحقّق يعيش في مكان واحد فقط، والواجهة تقرؤه بدل أن تكرّره.

الخطوة 6: إعادة المحاولة بتأخير أُسّي

استدعاءات الشبكة تفشل. ومسار الدفع المتين يعيد المحاولة تلقائيًا عند الأعطال العابرة، لكنّه يضع حدًّا للمحاولات. نمذج ذلك بحالة retrying وانتقال مؤجّل (after).

سجّل تأخيرًا ديناميكيًا في setup():

delays: {
  // ثانية، ثانيتان، أربع — تأخير أُسّي حسب عدد المحاولات
  backoff: ({ context }) => 2 ** context.retries * 1000,
},

ثم أعد هيكلة مسار الفشل:

failure: {
  // أعد المحاولة تلقائيًا إن بقيت محاولات
  always: {
    target: 'retrying',
    guard: 'canRetry',
  },
  on: {
    RESET: { target: 'editing', actions: 'clearError' },
  },
},
 
retrying: {
  entry: assign({ retries: ({ context }) => context.retries + 1 }),
  after: {
    // انتظر التأخير المحسوب ثم أعد المحاولة
    backoff: { target: 'submitting' },
  },
  on: {
    CANCEL: { target: 'editing' },
  },
},

ثلاث أفكار تعمل هنا فعليًا.

انتقال always (ويُسمّى الانتقال بلا حدث) يُطلَق لحظة دخول الحالة إن نجح حارسه. لذا تُحوّل failure فورًا إلى retrying ما دامت هناك محاولات، وتبقى مكانها — بانتظار RESET بشري — حالما تُرجع canRetry قيمة خاطئة.

وكتلة after تجدول انتقالًا بمؤقّت. ولأنّ backoff دالّة تأخير مُسجَّلة، فهي تُحسب من جديد عند كل دخول: ثانية، ثم ثانيتان، ثم أربع.

والمؤقّت مِلك للحالة. فإن ضغط المستخدم CANCEL أثناء الانتظار، تغادر الآلة retrying ويُلغى المؤقّت المعلّق معها. لن يعمل setTimeout شارد بعد ثانيتين على مكوّن أُزيل من الشجرة. قارن ذلك بما يبدو عليه هذا المنطق مع useEffect ومرجع يحمل معرّف المؤقّت.

الخطوة 7: توليد ممثّلين أبناء للرفع المتوازي

الممثّلون المُستدعَون يحيون ويموتون مع الحالة. لكن أحيانًا تحتاج إلى عدد ديناميكي من الممثّلين طويلي العمر — واحد لكل ملف مرفوع، ولكلٍّ تقدّمه المستقلّ. هنا يأتي دور التوليد.

أولًا آلة رفع صغيرة، لكل ملف نسخته الخاصة:

import { setup, fromPromise, assign } from 'xstate'
 
export const uploadMachine = setup({
  types: {
    context: {} as { file: File; progress: number; url: string | null },
    input: {} as { file: File },
  },
  actors: {
    uploadFile: fromPromise<string, { file: File }>(async ({ input }) => {
      const body = new FormData()
      body.append('file', input.file)
      const res = await fetch('/api/upload', { method: 'POST', body })
      const data = await res.json()
      return data.url as string
    }),
  },
}).createMachine({
  id: 'upload',
  // input يغذّي السياق الابتدائي — هكذا يستبدل الإصدار الخامس withContext()
  context: ({ input }) => ({ file: input.file, progress: 0, url: null }),
  initial: 'uploading',
  states: {
    uploading: {
      invoke: {
        src: 'uploadFile',
        input: ({ context }) => ({ file: context.file }),
        onDone: {
          target: 'done',
          actions: assign({ url: ({ event }) => event.output }),
        },
        onError: { target: 'failed' },
      },
    },
    done: { type: 'final' },
    failed: {
      on: { RETRY: { target: 'uploading' } },
    },
  },
})

ثم ولّد نسخة لكل ملف من الآلة الأمّ عبر enqueueActions:

import { enqueueActions } from 'xstate'
 
// داخل setup() في الآلة الأمّ:
actors: {
  chargeCard: /* ... */,
  uploadMachine,
},
 
// داخل حالات الآلة الأمّ:
on: {
  'FILES.ADD': {
    actions: enqueueActions(({ enqueue, event }) => {
      for (const file of event.files) {
        enqueue.spawnChild('uploadMachine', {
          id: `upload-${file.name}`,
          input: { file },
          syncSnapshot: true,
        })
      }
    }),
  },
},

كلّ ابن مولَّد هو ممثّل مستقلّ تمامًا، بحالته الخاصة، ومنطق إعادة المحاولة الخاص به، ودورة حياته الخاصة. فشل رفع واحد لا يمسّ البقيّة. ولأنّ uploadMachine آلة قائمة بذاتها، يمكنك اختبارها وحدويًا دون بناء أي مسار دفع.

الخطوة 8: الحالة العامة عبر createActorContext

يحصر useMachine الممثّل في مكوّن واحد. وحين تحتاج مكوّنات متباعدة إلى الآلة نفسها — شارة السلّة في الترويسة، والملخّص في الشريط الجانبي، ونموذج الدفع — ارفعها بـ createActorContext.

// src/machines/checkout.context.tsx
'use client'
 
import { createActorContext } from '@xstate/react'
import { checkoutMachine } from './checkout.machine'
 
export const CheckoutMachineContext = createActorContext(checkoutMachine)

غلّف شجرتك مرّة واحدة:

export function Providers({ children }: { children: React.ReactNode }) {
  return <CheckoutMachineContext.Provider>{children}</CheckoutMachineContext.Provider>
}

ثم اقرأها من أي مكان — وهنا تحديدًا يهمّ الأداء:

export function CartBadge() {
  // useSelector يشترك في شريحة واحدة فقط: يُعاد رسم هذا المكوّن
  // فقط حين تتغيّر cartTotal فعليًا، لا عند كل انتقال في الآلة.
  const total = CheckoutMachineContext.useSelector((s) => s.context.cartTotal)
  const send = CheckoutMachineContext.useActorRef().send
 
  return <button onClick={() => send({ type: 'SUBMIT' })}>السلّة: {total} د.ت</button>
}

استخدم useSelector بدل سحب اللقطة كاملة. فالآلة داخل حلقة إعادة محاولة تنتقل عدّة مرّات في الثانية، والمكوّن الذي يختار cartTotal فقط ينام خلال ذلك كلّه. هذا هو المبدأ ذاته وراء محدّدات Zustand وJotai، وهو الفرق بين تطبيق سلس وآخر متقطّع.

الخطوة 9: حفظ الحالة عبر عمليات إعادة التحميل

يحدّث المستخدم الصفحة في منتصف الدفع. مع useState يضيع المسار كلّه. أمّا ممثّلو XState فيسلسلون لقطتهم بالكامل — الحالة الراهنة والسياق والممثّلون الأبناء.

'use client'
 
import { useMachine } from '@xstate/react'
import { checkoutMachine } from '@/machines/checkout.machine'
 
const STORAGE_KEY = 'checkout-snapshot'
 
function loadSnapshot() {
  if (typeof window === 'undefined') return undefined
  const raw = window.localStorage.getItem(STORAGE_KEY)
  return raw ? JSON.parse(raw) : undefined
}
 
export function PersistentCheckout() {
  const [snapshot, send, actorRef] = useMachine(checkoutMachine, {
    snapshot: loadSnapshot(),
  })
 
  useEffect(() => {
    const sub = actorRef.subscribe(() => {
      const persisted = actorRef.getPersistedSnapshot()
      window.localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted))
    })
    return () => sub.unsubscribe()
  }, [actorRef])
 
  // ...
}

نقطتان تستحقّان التنبيه.

getPersistedSnapshot() ليست الكائن نفسه الذي في snapshot. إنّها تمثيل قابل للتسلسل، مجرّد من الدوالّ والمراجع الحيّة، ومصمّم للنجاة من JSON.stringify — وهذا التمييز يوقع الجميع تقريبًا في المرّة الأولى.

والاستعادة ليست إعادة تشغيل. تستأنف الآلة داخل الحالة المحفوظة؛ ولا تعيد تنفيذ الانتقالات التي أوصلتها إليها. لذا فاللقطة المحفوظة أثناء submitting ستُستعاد إلى submitting وتستدعي chargeCard فورًا من جديد. وهذا سلوك خاطئ في مسار دفع — ستخصم المبلغ مرّتين. احفظ من الحالات الآمنة فقط، أو أضف حارس دخول يحوّل حالة submitting المستعادة إلى حالة "تحقّق من حالة الطلب" تسأل واجهتك البرمجية عمّا إذا كان الخصم قد تمّ فعلًا.

الخطوة 10: التصحيح البصري بمفتّش Stately

هذه هي الميزة التي تُقنع الزملاء المتشكّكين. مرّر خيار inspect وشاهد آلتك تعمل كمخطّط حيّ.

'use client'
 
import { createBrowserInspector } from '@statelyai/inspect'
 
const inspector =
  process.env.NODE_ENV === 'development' ? createBrowserInspector() : undefined
 
export function Checkout() {
  const [snapshot, send] = useMachine(checkoutMachine, {
    inspect: inspector?.inspect,
  })
  // ...
}

شغّل التطبيق فيُفتح تبويب يعرض مخطّط حالاتك. كلّ حدث، وكلّ تقييم لحارس، وكلّ تغيّر في السياق يتحرّك أمامك في الزمن الحقيقي. ويتوقّف تصحيح مسار عالق عن كونه حفريّات في console.log — فأنت ترى حرفيًا أي انتقال انطلق أو لم ينطلق، وأي حارس منعه.

أبقِ المفتّش خلف فحص NODE_ENV كي لا يصل إلى الإنتاج أبدًا.

اختبار آلتك

لأنّ الآلة كائن نقيّ مستقلّ عن الإطار، يمكنك اختبار كامل منطقك دون رسم مكوّن React واحد. إليك اختبارًا بـ Vitest:

import { describe, it, expect } from 'vitest'
import { createActor, fromPromise, waitFor } from 'xstate'
import { checkoutMachine } from './checkout.machine'
 
describe('checkoutMachine', () => {
  it('يرفض إرسال سلّة غير صالحة', () => {
    const actor = createActor(checkoutMachine).start()
 
    actor.send({ type: 'SUBMIT' })
 
    // الحارس منع الانتقال، فلم نغادر editing
    expect(actor.getSnapshot().matches('editing')).toBe(true)
  })
 
  it('يخصم من البطاقة ويصل إلى success', async () => {
    // provide() تستبدل ممثّلًا حقيقيًا بآخر وهمي دون مكتبة محاكاة
    const testMachine = checkoutMachine.provide({
      actors: {
        chargeCard: fromPromise(async () => ({ orderId: 'ord_123' })),
      },
    })
 
    const actor = createActor(testMachine).start()
    actor.send({ type: 'EMAIL.CHANGE', email: 'a@b.com' })
    actor.send({ type: 'CART.UPDATE', total: 100 })
    actor.send({ type: 'SUBMIT' })
 
    // waitFor تُحلّ حالما يتحقّق الشرط
    const finalState = await waitFor(actor, (s) => s.matches('success'))
 
    expect(finalState.context.orderId).toBe('ord_123')
  })
})

machine.provide() هي القوّة الخارقة في الاختبار. تُرجع آلة جديدة بتنفيذات مستبدَلة، دون المساس بمخطّط الحالات. لا vi.mock، ولا اعتراض للوحدات، ولا إطار لحقن التبعيات. يصبح ممثّل الدفع بديلًا من سطرين، وتبقى الآلة قيد الاختبار هي نفسها التي تعمل في الإنتاج.

استكشاف الأخطاء وإصلاحها

"الأحداث تُتجاهَل ولا يحدث شيء." هذا هو XState يعمل بشكل صحيح. فالحدث الذي لا يقابله انتقال في الحالة الراهنة لا يفعل شيئًا، بالتصميم. افتح المفتّش وتحقّق من الحالة التي أنت فيها فعلًا — غالبًا أرسلتَ SUBMIT وأنت في submitting أصلًا.

"إجرائي ينطلق لكن السياق لا يتحدّث." على الأرجح غيّرت السياق مباشرةً. يجب أن تُرجع assign قيمة جديدة؛ وcontext.retries++ داخل إجراء لا يفيد شيئًا. أرجِع { retries: context.retries + 1 } بدلًا من ذلك.

"TypeScript لا يستنتج أنواع أحداثي." يجب أن يكون اتحاد أحداثك اتحادًا مميّزًا بالمفتاح type، ويجب تسجيله في setup({ types: { events: {} as MyEvents } }). والتحويل {} as T مقصود: أنت تزوّد نوعًا لا قيمة.

"الممثّل المُستدعى يعمل مرّتين أثناء التطوير." هذا هو التركيب المزدوج في الوضع الصارم بـ React 18. سلوك متوقّع ولا يحدث في الإنتاج، لكنّه إنذار حقيقي بشأن الآثار الجانبية غير المتكافئة كالمدفوعات. اجعل نقطة النهاية لديك متكافئة عبر مفتاح طلب.

"اللقطة المحفوظة لا تُستعاد." غالبًا خزّنت snapshot بدل actorRef.getPersistedSnapshot(). الثانية وحدها قابلة للتسلسل.

متى لا تستخدم XState

نصيحة صريحة: XState ليس مجّانيًا. فهو يضيف عبئًا مفاهيميًا لا يحتاجه تطبيق بسيط.

تجنّبه في المفاتيح المنطقية، والنماذج البسيطة، وجلب بيانات الخادم العادي — فـ useState يكفي للحالتين الأوليين، وTanStack Query يتولّى الثالثة أفضل بكثير من أي آلة. والجأ إلى XState حين يحوي المنطق أوضاعًا حقيقية بسلوك مختلف، وحين تحكم الانتقالات بينها قواعد، وحين يكلّف الخطأ في تلك القواعد مالًا أو بيانات حقيقية: مسارات الدفع، والمعالجات متعدّدة الخطوات، ومشغّلات الوسائط، والسحب والإفلات، والتهيئة الأولية، وتنسيق الوكلاء الأذكياء.

قاعدة تقريبية جيّدة: إن كنت قد رسمت المسار على لوح أبيض كصناديق وأسهم، فـ XState يتيح لك شحن اللوح نفسه.

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

  • استكشف @xstate/store، بديل أخفّ بكثير لحالة مشتركة بسيطة دون آليّات مخطّطات الحالة
  • نمذج الحالات المتوازية للاهتمامات المستقلّة التي تعمل في الوقت نفسه، مثل مشغّل وسائط يتتبّع التشغيل ومستوى الصوت منفصلَين
  • استخدم ممثّلي fromCallback لتغليف اتصالات WebSocket ومُطلقات الأحداث
  • جرّب المحرّر البصري Stately Studio الذي ينقل آلتك ذهابًا وإيابًا بين الكود والمخطّط
  • قارن هذا النهج بدروسنا حول Zustand لإدارة الحالة في Next.js والحالة الذرّية مع Jotai
  • طبّق نموذج الممثّل على وكلاء الذكاء الاصطناعي عبر دليلنا LangGraph.js والوكلاء ذوو الحالة

الخاتمة

بنيتَ مسار دفع لا يمكنه الدخول في حالة مستحيلة — لأنّ الحالات المستحيلة غير موجودة أصلًا في النموذج. وفي الطريق غطّيت واجهة setup() واستنتاجها للأنواع، وممثّلي الوعود عبر fromPromise، والحُرّاس الذين يقودون أزرارك المعطّلة تلقائيًا، وإعادة المحاولة بتأخير أُسّي مع تنظيف بنيوي، والممثّلين الأبناء للعمل المتوازي، وcreateActorContext مع محدّدات دقيقة، وحفظ اللقطات.

لكنّ التحوّل الأعمق هو في مكان إقامة المنطق. ففي نسخة useState تتناثر قواعد عملك بين معالجات الأحداث والتأثيرات وشروط JSX — ولا يمكنك رؤيتها كلّها دفعة واحدة أبدًا. أمّا في نسخة XState فهي تعيش في كائن واحد تقرؤه من أعلاه إلى أسفله، وتراه كمخطّط، وتختبره دون DOM، وتسلّمه لمصمّم سيفهمه فعلًا.

المتغيّرات المنطقية تصف ما هي عليه واجهتك. أمّا آلات الحالة فتصف ما يُسمح لها أن تصير إليه. وحيثما تكون الصحّة مهمّة، فالسؤال الثاني هو الجدير بالإجابة.