🇮🇩 Membuat FSM dengan XState

ibedwi

Ibé Dwi

Posted on December 7, 2023

🇮🇩 Membuat FSM dengan XState

NOTE:
Ketika tulisan ini dibuat, saya masih menggunakan XState versi 4. Saya mungkin akan menuliskan perubahan atau perbedaan antara XState versi 4 dan 5. Tapi pada dasarnya, konsep yang digunakan sama.

Di tulisan ini, saya ingin berbagi tentang finite state machine dan bagaimana membuat FSM dengan XState. Saya juga membagikan hasil akhir dari project yang ada di tulisan ini di link berikut.

Menyiapkan project

Saya menggunakan Next.js 13 dengan app directory dan TailwindCSS. Tutorial membuat project Next.js dengan app router dapat ditemukan di link ini.

What is your project named? logs-understanding-fsm-with-xstate
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? Yes
What import alias would you like configured? @/*
Enter fullscreen mode Exit fullscreen mode

Libraries lain yang dibutuhkan akan saya jelaskan pada subbab terkait.

Apa itu Finite State Machine?

Apa itu finite state machine? Cara saya memahami istilah ini adalah dengan mencoba membedah dan memahami tiap kata yang ada dalam istilah ini:

Machine
Machine di sini merujuk kepada “a model of a system”. Model sendiri bisa diartikan sebagai sebuah representasi yang informatif dari sesuatu (yaa intinya representasi dari sistem).

State
State di sini merujuk kepada informasi. Lebih spesifik lagi, informasi yang dimaksud adalah behavior (perilaku atau tingkah laku) dari sebuah system.

Jika dua kata ini digabungkan, “state machine” dapat dipahami sebagai representasi dari behavior sebuah system. Sebuah state machine, tentunya terdiri dari daftar tingkah lakunya. State machine juga mendeskripsikan transisi dari satu state ke state lain. Transisi ini dipicu oleh input yang diberikan kepada state machine tersebut.

Kata terakhir, “finite”, memiliki makna bahwa state yang ada di dalam state machine jumlahnya terbatas.

Memodelkan finite state machine kipas angin

Berhubung cuaca di tempat saya tinggal saat menulis tulisan ini sedang panas, maka kita akan menggunakan kipas angin sebagai contoh kasus. Kita akan membuat model kipas angin dengan menggunakan FSM.

Memasang XState

Sebelum mulai memodelkan FSM kipas angin, kita harus meng-install beberapa libraries yang berkaitan dengan finite state machine terlebih dahulu:

yarn add xstate@^4.38.1 @xstate/react@^3.2.2
Enter fullscreen mode Exit fullscreen mode

VSCode Extension

Untuk kemudahan development, saya menggunakan extension Stately di VSCode. Extension ini sangat memudahkan dalam memodelkan FSM karena menggunakan GUI.

Pemodelan FSM kipas angin

Untuk membuat sebuah FSM dengan XState, kita bisa menggunakan fungsi createMachine:

import { createMachine } from "xstate";
export const fanMachine = createMachine({
  id: "fan",
});
Enter fullscreen mode Exit fullscreen mode

Property id digunakan untuk mengidentifikasi mesin yang kita buat.

Untuk memodelkan bagaimana sebuah kipas angin bekerja, kita bisa mulai dengan pertanyaan: “apa saja state dari sebuah kipas angin?”. Mudah, stop dan spin. stop artinya kipas angin kita tidak berputar, sedangkan spin artinya berputar. Intinya ada keadaan ketika kipas angin menyala dan mati. Kita bisa menuliskan state dari kipas angin ini ke dalam definisi machine kita sebagai berikut:

import { createMachine } from "xstate";

export const fanMachine = createMachine({
  id: "fan",

  states: {
    stop: {},
    spin: {},
  },

  initial: "off",
});
Enter fullscreen mode Exit fullscreen mode

Property states adalah daftar state apa saja yang dimiliki oleh machine kita dan property initial menentukan state awal saat machine pertama kali dijalankan.

Sekarang, machine kita sudah punya state. Tapi, kita belum menentukan bagaimana machine kita bisa berpindah dari state stop ke spin.


(Tips) Typescript support (you can skip this part if you’re not using TypeScript)

Jika kamu juga menggunakan TypeScript, XState menyediakan types generator (typegen) dari machine kita. Berdasarkan dokumentasi ini, pengguna VSCode hanya perlu meng-install sebuah extension.

Lalu di dalam definisi state machine kita, kita hanya perlu menambahkan property tsTypes: {}. Ketika kita menyimpan file tersebut, typings dari state machine kita akan otomatis terbuat.


Transisi state

Dalam XState, transisi dari sebuah state ke state lain dipicu oleh sebuah event. event di sini sepadan dengan “input” yang kita singgung pada bagian pengertian FSM di atas.

Untuk kasus kita, transisi yang kita butuhkan untuk berpindah adalah transisi state stop ke spin dan sebaliknya. Transisi ini dipicu oleh user yang menyalakan atau mematikan kipas angin. Dalam XState, kita bisa menulis transisi ini pada property on dari sebuah state:

export const fanMachine = createMachine({
  id: "fan",

  states: {
    stop: {
      on: {
        "USER.PRESS.ON": "spin",
      },
    },

    spin: {
      on: {
        "USER.PRESS.OFF": "stop",
      },
    },
  },

  initial: "stop",
});
Enter fullscreen mode Exit fullscreen mode

Kurang lebih, transisi pada state stop bisa dibaca: “Ketika USER.PRESS.ON, transisi ke spin”.

Yang menarik dari XState, kita bisa menggunakan nilai string apa saja sebagai nama nilai EVENT. Di sini saya menggunakan convention nama aksi yang ditulis dalam uppercase dan tiap kata dipisahkan oleh titik alih-alih oleh spasi.

Untuk menghasilkan generated typings yang lebih baik, kita bisa menuliskan EVENT apa saja yang dikenali oleh machine kita. Misalnya:

type MachineEvent = { type: "USER.PRESS.ON" } | { type: "USER.PRESS.OFF" };

export const fanMachine = createMachine({
  id: "fan",
  tsTypes: {} as import("./fanMachine.fsm.typegen").Typegen0,
  schema: {
    events: {} as MachineEvent,
  },
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Memperkaya state machine dengan informasi tambahan

Kita sudah berhasil membuat kipas angin kita menyala dan mati. Tapi, bagaimana dengan kecepatan putaran kipas? Di XState, informasi tambahan (atau sederhananya, data) yang diketahui oleh machine disimpan ke dalam context.

Dalam kasus kita, informasi tambahan yang dibutuhkan adalah kecepatan dari kipas.

Kita bisa menambahkan kecepatan ke dalam property context:

export const fanMachine = createMachine({
  id: "fan",
  context: {
    fanSpeed: 0,
  },
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Kita juga bisa membuat type untuk context dan menambahkannya ke dalam schema:

type MachineContext = {
  fanSpeed: number;
};

// ...

export const fanMachine = createMachine({
  id: "fan",
  schema: {
    events: {} as MachineEvent,
    context: {} as MachineContext,
  },
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Sekarang, machine kita memilki informasi tambahan, fanSpeed, yang disimpan di dalam context.

Mengubah nilai dari context dengan action

Sejauh ini, kita sudah menambahkan kecepatan kipas, fanSpeed , ke dalam machine melalui context. Tapi, ketika state transisi dari stop ke spin, fanSpeed dari kipas angin kita masih 0!

Untuk merubah nilai dari context, kita bisa memanfaatkan salah satu fitur dari XState, yaitu action.

Di XState, action adalah salah satu bentuk side-effect yang bisa di-trigger. Kapan action ke-trigger? action bisa di-trigger ketika perpindahan state, yaitu ketika masuk atau keluar state, atau di-trigger oleh event. action dalam XState adalah pure function; umumnya bersifat synchronous. Kita bisa menggunakan action untuk merubah nilai dari context.

Menambahkan action pada event

Pertama, mari kita update event yang kita kirim ketika transisi dari stop ke spin dan sebaliknya untuk memicu action yang akan merubah nilai dari fanSpeed. Mari kita beri nama action ini changeFanSpeed. action ini ditambahkan pada property actions yang ada di dalam event.

export const fanMachine = createMachine({
  id: "fan",
  // ...
  states: {
    stop: {
      on: {
        "USER.PRESS.ON": {
          target: "spin",
          // v let's add action here!
          actions: "changeFanSpeed",
        },
      },
    },

    spin: {
      on: {
        "USER.PRESS.OFF": {
          target: "stop",
          // v let's add action here!
          actions: "changeFanSpeed",
        },
      },
    },
  },
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Menulis implementasi dari action changeFanSpeed

Langkah selanjutnya adalah menuliskan implementasi dari action changeFanSpeed.

Berdasarkan dokumentasinya, fungsi createMachine menerima 2 argumen, yang pertama adalah konfigurasi machine dan yang kedua adalah options. Salah satu property dari options adalah actions, tempat di mana kita menuliskan implementasi dari actions. Hampir setiap property yang ada di options — entah itu guards, actions, atau services — yang berbentuk fungsi, akan menerima 2 argumen secara berurutan yaitu context saat action tersebut di-trigger dan event yang men-trigger action tersebut. Kita dapat menuliskan action changeFanSpeed seperti ini:

export const fanMachine = createMachine(
  {
    // ...
  },
  {
    actions: {
      changeFanSpeed: (_context, event) => {
        /* implementation goes here */
      },
    },
  }
);
Enter fullscreen mode Exit fullscreen mode

Tetapi, untuk merubah context, kita butuh built-in action dari XState yang disebut “assign action”. Sederhananya, assign action ini adalah fungsi yang menerima nilai baru yang akan diterapkan ke context dan akan menetapkan nilai tersebut ke dalam context. Jika nilai dari context terbaru yang kita inginkan adalah hasil dari action , kita cukup membungkus action tersebut ke menggunakan fungsi assign.

import { assign } from "xstate";

export const fanMachine = createMachine(
  {
    // ...
  },
  {
    actions: {
      changeFanSpeed: assign((_context, event) => {
        /* implementation goes here */
      }),
    },
  }
);
Enter fullscreen mode Exit fullscreen mode

Misal, nilai fanSpeed ketika kipas angin pertama kali dinyalakan adalah 1. Kita bisa menuliskan assign action changeFanSpeed seperti ini:

export const fanMachine = createMachine(
  {
    id: "fan",
    // ...
    states: {
      stop: {
        on: {
          "USER.PRESS.ON": {
            target: "spin",
            actions: "changeFanSpeed",
          },
        },
      },

      spin: {
        on: {
          "USER.PRESS.OFF": {
            target: "stop",
            actions: "changeFanSpeed",
          },
        },
      },
    },
    // ...
  },
  {
    actions: {
      changeFanSpeed: assign((_context, event) => {
        if (event.type === "USER.PRESS.ON") {
          return {
            fanSpeed: 1,
          };
        }
        if (event.type === "USER.PRESS.OFF") {
          return {
            fanSpeed: 0,
          };
        }
        return {};
      }),
    },
  }
);
Enter fullscreen mode Exit fullscreen mode

Sekarang, kita sudah memilki FSM kipas angin!

Selanjutnya, salah satu bagian yang tak kalah seru: mengintegrasikan FSM yang sudah kita buat ke UI!

Jika kamu juga menggunakan extension VSCode, FSM kita saat ini kurang lebih terlihat seperti ini:

FSM with XState Result

Mengintegrasikan state machine ke UI

Sebelum melanjutkan, kita perlu meng-install beberapa libraries terlebih dahulu:

yarn add framer-motion@^10.16.12 react-icons@^4.12.0
Enter fullscreen mode Exit fullscreen mode

Mengintegrasikan FSM ke React component

Pertama-tama, mari kita buat sebuah component bernama Fan.tsx. Component ini bisa dianggap sebagai representasi visual dari kipas angin.

Dalam XState, machine yang sudah kita definisikan, fanMachine , bisa dianggap sebagai definisi dari sebuah proses. Sedangkan proses yang berjalan berdasarkan definisi yang sudah kita buat disebut sebagian service atau actor. Beberapa waktu terakhir, saya rasa istilah actor lebih sering digunakan.

XState menyediakan hook bernama useMachine untuk membuat actor (proses) dari machine yang sudah kita definisikan. Hook ini mengembalikan tuple, yang berisi informasi dari actor yang sedang berjalan dalam bentuk object, fungsi untuk mengirim event ke actor, dan referensi ke actor yang sudah dibuat. Selain itu, hook ini juga mengikat actor ke dalam lifecycle dari component. Jadi ketika component di-unmount, actor akan berhenti dan akan mulai dari awal lagi (dari initial state) ketika component di-mount.

export function Fan() {
  const [fsmState, fsmSendEvent] = useMachine(fanMachine);

  const isOn = fsmState.matches("spin");
  const speed = fsmState.context.fanSpeed;

  return <div>{/* ... */}</div>;
}
Enter fullscreen mode Exit fullscreen mode

isOn menyimpan hasil dari method matches. Method ini digunakan untuk memastikan apakahstate dari actor saat ini sesuai dengan argumen yang diberikan. Sedangkan untuk mengakses context, kita bisa menggunakan property context dari informasi yang kita dapatkan dari tuple yang dikembalikan oleh hook useMachine, fsmState.

Membuat UI

Berikut adalah UI sederhana yang menggambarkan kipas angin:

// utils to merge an array of `className`s
function cn(...classes: any[]) {
  return classes.filter(Boolean).join(" ");
}

export function Fan() {
  // ...
  return (
    <div className="flex flex-col items-stretch py-3 px-4 bg-gray-200 rounded-lg gap-5">
      <div>
        <FaDotCircle
          className={cn("text-md", isOn ? "text-green-400" : "text-red-400")}
        />
      </div>
      <FaFan className="text-8xl text-gray-500" />
      <div className="flex flex-row item-center justify-between w-full gap-[150px]">
        <button
          className={cn(
            "p-2 rounded-lg",
            !isOn ? "bg-red-300 text-white" : "bg-red-400 text-black"
          )}
        >
          Off
        </button>
        <button
          className={cn(
            "p-2 rounded-lg",
            isOn ? "bg-green-300 text-white" : "bg-green-400 text-black"
          )}
        >
          On
        </button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Mengirim event ke actor

UI kita sudah selesai dan sekarang kita ingin bisa mngirim event ke actor yang sudah kita buat. Kita bisa menggunakan fsmSendEvent yang sudah kita dapatkan sebelumnya. (Salah satu hal yang paling saya sukai adalah suggestions dari IDE ketika menuliskan event yang ingin kita kirim).

export function Fan() {
  // ...
  return (
    <div className="flex flex-col items-stretch py-3 px-4 bg-gray-200 rounded-lg gap-5">
      {/* ... */}
      <div className="flex flex-row item-center justify-between w-full gap-[150px]">
        <button
          // ...
          onClick={() => fsmSendEvent({ type: "USER.PRESS.OFF" })}
        >
          Off
        </button>
        <button
          // ...
          onClick={() => fsmSendEvent({ type: "USER.PRESS.ON" })}
        >
          On
        </button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Hanya dengan mengirim event, sekarang machine kipas angin sudah terintegrasi dengan React component kita! Tapi masih ada satu hal lagi yang kurang, yaitu animasi. Tenang, ini tidak akan lama ;)

Menambahkan animasi putaran kipas

Pada bagian animasi, kita menggunakan framer-motion. Kita ingin memutar baling-baling kipas berdasarkan kondisi menyala atau mati dengan kecepatan yang sudah ditentukan. Mengingat tulisan ini bukanlah tentang animasi, saya tidak akan memberikan penjelasan panjang lebar tentang bagaimana menggunakan framer-motion secara detail.

export function Fan() {
  // animation values
  const calculatedSpeed = isOn ? 1000 - 100 * speed : 0;
  const time = useTime();
  const rotate = useTransform(
    time,
    [0, calculatedSpeed], // For every calculatedSpeed,
    [0, -360], // rotate 360 degrees to the left direction
    {
      clamp: false, // make it rotate forever
    }
  );

  return (
    <div className="flex flex-col items-stretch py-3 px-4 bg-gray-200 rounded-lg gap-5">
      <motion.div
        style={{ rotate }}
        className="flex justify-center items-center"
      >
        <FaFan className="text-8xl text-gray-500" />
      </motion.div>
      {/* ... */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pada baris kode di atas, kita menggunakan rotate yang dihasilkan dari hook useTransform milik framer motion.

Demo

Loom

Summary

Di tulisan ini, kita sudah belajar tentang konsep finite state machine dan implementasinya menggunakan XState di Next.js.

Saya sendiri yakin bahwa XState adalah salah satu library yang bermanfaat dalam situasi yang kompleks.

Terima kasih sudah membaca!

đź’– đź’Ş đź™… đźš©
ibedwi
Ibé Dwi

Posted on December 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related