Actor model là một mô hình lập trình dùng để quản lý trạng thái (state) và giao tiếp bất đồng bộ (asynchronous) giữa các thành phần của hệ thống.

Mỗi actor là một thực thể (entity) độc lập, có thể

  • có trạng thái riêng, và không chia sẻ với các actor khác
  • nhận và gửi message từ các actor khác
  • actor sử lý các message tuần tự, và mỗi actor có một “mailbox” riêng để sử lý
  • có thể sinh ra (spawn) các actor con

Trong XState (v5 trở đi), actor không chỉ là state machine, mà có thể là bất cứ thứ gì biết cách nhận và xử lý message, ví dụ, một actor có thể là:

  • một state machine khác
  • một callback hoặc promise actor
  • một observable
  • một function
Ví dụ đơn giản

Giả sử, ta có một state machine quản lý người dùng đăng nhập, và cần gọi API lấy profile, thay vì nhúng API call thẳng vào machine, ta tạo một actor riêng để làm việc đó.

import { createMachine, fromPromise, interpret } from "xstate";
 
const fetchUserActor = fromPromise(async (context, event) => {
  const res = await fetch(`/api/user?id=${event.userId}`);
  return res.json();
});
 
const userMachine = createMachine({
  id: 'user',
  initial: 'idle',
  states: {
    idle: {
      on: {
        FETCH: {
          target: 'loading',
          actions: 'spawnFetchActor'
        }
      }
    },
    loading: {
      on: {
        'done.invoke.fetch': {
          target: 'success',
          actions: (_, event) => console.log('User:', event.output)
        },
        'error.invoke.fetch': 'failure'
      }
    },
    success: {},
    failure: {}
  }
}, {
  actions: {
    spawnFetchActor: (context, event, { spawn }) => {
      spawn(fetchUserActor, { id: 'fetch', input: event });
    }
  }
});

Ở đây fetchUserActor là một actor riêng biệt, có vòng đời độc lập, và tự gửi lại kết quả done.invoke.fetch cho parent machine khi xong.

Khoan, vậy sao lại không chỉ dùng function mà lại dùng actor cho phức tạp? Đúng là chúng ta có thể chỉ dùng function gọi API mà không cần actor, nhưng việc tạo actor rồi spawn/invoke trong XState có lý do của nó, đặc biệt khi nhìn từ góc độ mô hình trạng thái và quản lý side effect.

actions: {
  fetchUser: async (context, event) => {
    const res = await fetch(`/api/user?id=${event.userId}`);
    const data = await res.json();
    console.log("Fetched:", data);
  },
}

Ta có thể thay actor bằng function như trên, cách này có điểm mạnh là đơn giản, nhanh, và phù hợp cho các tác vụ nhỏ. Nhưng bản chất nó chỉ là side-effect tức thời. Nó không có vòng đời riêng, không thể quản lý, quan sát, và không thể tự động gửi event ngược lại cho parent machine.

Ngoài ra nó còn có thể tái sử dụng, hoặc chạy song song. Ví dụ như ta có thể có nhiều API fetch khác nhau

spawn(fetchUserActor, { id: 'fetchUser', input: { userId: 1 } });
spawn(fetchPostsActor, { id: 'fetchPosts', input: { userId: 1 } });

Và phù hợp với tư duy Actor model, mọi đơn vị có trạng thái hoặc side-effect đều là actor, fetchUserActor là một tác nhân bất đồng bộ, nên thể hiện đúng mô hình bằng spawn.

Vậy khi nào thì chỉ dùng function

  • khi chỉ có 1 tác vụ async duy nhất (không cần quản lý)
  • không cần cancel hoặc reuse
  • muốn code gọn hơn, ít cấp bậc hơn

Ngoài ra, ta có thể viết lại ví dụ trên sử dụng invoke

const userMachine = createMachine({
  id: 'user',
  initial: 'idle',
  states: {
    idle: {
      on: { FETCH: 'loading' }
    },
    loading: {
      invoke: {
        src: (_, event) => fetch(`/api/user?id=${event.userId}`).then(r => r.json()),
        onDone: {
          target: 'success',
          actions: (_, event) => console.log('User:', event.output)
        },
        onError: 'failure'
      }
    },
    success: {},
    failure: {}
  }
});

Đây là cách thường được sử dụng cho async logic thuộc về một state. \

Trong ví dụ trên, khi state rời khỏi loading actor con invoke sẽ tự động bị dừng. Ở đây, khi API đang chạy, nó sẽ bị huỷ tự động để tránh race-condition.

Vậy nó khác gì với cách sử dụng spawn?

invoke hay spawn đều được sử dụng để khởi tạo actor chỉ khác ở:

  • invoke tự động khởi tạo khi state được kích hoạt, khác với spawn phải gọi thủ công
  • vòng đời của invoke gắn liền với state, state rời đi actor bị stop. Còn spawn thì gắn liền với parent machine, tồn tại cho đến khi stop thủ công.

Vì vậy, invoke được sử dụng trong các tác vụ async liên kết với một state cụ thể. Còn spawn thích hợp cho tác vụ dài hạn, có thể sống song song hoặc tái sử dụng.

  actions: {
    spawnFetchActor: (context, event, { spawn, sendTo }) => {
      const actor = spawn(fetchUserActor, { id: "fetch", input: event });
      // Tự handle khi actor hoàn tất:
      actor.subscribe({
        complete: () => console.log("Fetch done"),
        next: (snapshot) => {
          sendTo("user", { type: "USER_SUCCESS", data: snapshot.output });
        },
        error: (err) => {
          sendTo("user", { type: "USER_FAILURE", error: err });
        },
      });
    },
  },