Joke bot with a state machine using XState

Joke bot with a state machine using XState

12 days ago, April 13, 2024
Reading time: 20 mins

In this blog post, we’ll build a Joke bot using XState, a popular state management library for JavaScript. Our joke bot will be able to tell jokes, respond to user input, and transition between different states based on the user’s messages.

The DEMO site can be found here: https://jokes-bot.netlify.app/

The Github repo for this project can be found here: https://github.com/dejavu1987/joke-bot

Prerequisites

Before we get started, you’ll need to have the following installed:

  • Node.js and npm
  • A code editor or IDE

Creating a New Project

I have created a GitHub template repo at https://github.com/dejavu1987/vite-react-tailwind-ts that has Vite, ReactJs, TailwindCSS, and typescript setup.
Go to the URL and click on “Use this Template” and select “Create a new repository“. Note, you will need a GitHub account to create a new repo.

The next step is to install some dependencies to use XState.

npm install --save xstate @xstate/react

Creating the state machine

I began by creating a simple machine in code and imported it into XState Visualizer. But you can start building your machine visually using the visual editor and later export it to code.

In this machine:

  • We define the initial state as “initial”.
  • We define three other states: “jokes”, “categories”, and “end”.
  • We define events that can trigger transitions between states.
  • We define a context object that stores the current joke category.

Note that we are using v5 of XState, and when you try googling you may mostly find solutions in v4.

The State machine’s code looks something like this.

import { setup } from "xstate";
export type JokeCategory = "dad joke" | "pun" | "chuck norris";
export const jokeBotMachine = setup({
  types: {
    context: {} as { currentCategory: JokeCategory },
    events: {} as
      | { type: "START" }
      | { type: "CHOOSE_CATEGORY"; category: string }
      | { type: "END" }
      | { type: "NEXT_JOKE" }
      | { type: "RESTART" },
  },
  schemas: {
    events: {
      START: {
        type: "object",
        properties: {},
      },
      CHOOSE_CATEGORY: {
        type: "object",
        properties: {
          category: {
            type: "string",
          },
        },
        description: "ahehlaksdf",
      },
      END: {
        type: "object",
        properties: {},
      },
      NEXT_JOKE: {
        type: "object",
        properties: {},
      },
      RESTART: {
        type: "object",
        properties: {},
      },
    },
    context: {
      currentCategory: {
        type: "string",
        description:
          'Category that user selects during categories state, our joke selection will depend on this context',
      },
    },
  },
}).createMachine({
  context: {
    currentCategory: "pun",
  },
  id: "jokeBot",
  initial: "initial",
  states: {
    initial: {
      on: {
        START: {
          target: "categories",
        },
      },
    },
    categories: {
      on: {
        CHOOSE_CATEGORY: {
          target: "jokes",
          actions: ({ context: ctx, event: event }) => {
            ctx.currentCategory = event.category as JokeCategory;
          },
        },
        END: {
          target: "end",
        },
      },
    },
    jokes: {
      on: {
        END: {
          target: "end",
        },
        NEXT_JOKE: {
          target: "categories",
        },
      },
    },
    end: {
      on: {
        RESTART: {
          target: "initial",
        },
      },
    },
  },
});

Using the Joke Bot Machine

Now that we have our XState machine, we can use it to create our joke bot. Let’s create the main component for our joke bot. We’ll call it Chat.tsx:

import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Message as MessageProps } from "@/types";
import { useEffect, useRef, useState } from "react";
import { useMachine } from "@xstate/react";
import Message from "./message";
import { JokeCategory, jokeBotMachine } from "@/jokeMachine";
import { jokes } from "@/data/jokes";

const defaultMessages: MessageProps[] = [
  {
    user: "Joke Bot",
    message:
      "Hi there! I am Joke Bot! I'm here to make you laugh! Do you want to hear a joke?",
  },
];
export function Chat() {
  const [state, send] = useMachine(jokeBotMachine);
  const messageList = useRef<HTMLDivElement>(null);
  const [messages, setMessages] = useState<MessageProps[]>([]);
  const [currentMessage, setCurrentMessage] = useState<string>("");

  useEffect(() => {
    if (messageList.current) {
      messageList.current.scrollTop = messageList.current.scrollHeight;
    }
  }, [messages]);

  useEffect(() => {
    switch (state.value) {
      case "initial":
        setMessages([...messages, ...defaultMessages]);
        break;
      case "categories":
        setMessages([
          ...messages,
          {
            user: "Jokes Bot",
            message: "Please select a category: Pun, Dad jokes, Chuck Norris",
          },
        ]);
        break;
      case "end":
        setMessages([
          ...messages,
          {
            user: "Jokes Bot",
            message:
              "Okay Bye! If you want to hear another joke, just type hi!",
          },
        ]);
        break;
      case "jokes":
        setMessages([
          ...messages,
          {
            user: "Jokes Bot",
            message:
              "Here's a joke:\n" +
              jokes[state.context.currentCategory][
                Math.floor(
                  Math.random() * jokes[state.context.currentCategory].length
                )
              ] +
              ".\nDo you want to hear another one?",
          },
        ]);
        break;
    }
  }, [state.value]);

  const sendMessage = () => {
    setMessages([
      ...messages,
      {
        user: "Me",
        message: currentMessage,
      },
    ]);
    if (currentMessage.match(/yes/i)) {
      console.log("Yes");
      send({ type: "START" });
      send({ type: "NEXT_JOKE" });
    }
    if (currentMessage && currentMessage.match(/pun|dad joke|chuck norris/i)) {
      console.log("cat select");
      send({
        type: "CHOOSE_CATEGORY",
        category: currentMessage.match(
          /pun|dad joke|chuck norris/i
        )![0] as JokeCategory,
      });
    }
    if (
      currentMessage.trim().match(/^no$/) ||
      currentMessage.match(/nope|end|bye|quit|stop|exit/i)
    ) {
      console.log("stop");
      send({ type: "END" });
    }
    if (currentMessage.match(/restart|hi|hello|new|init/i)) {
      console.log("restart");
      send({ type: "RESTART" });
    }
    console.log(state.value);
    setCurrentMessage("");
  };

  return (
    <div className="flex w-full max-w-2xl rounded-lg border border-gray-200 dark:border-gray-500 overflow-hidden flex-col dark:border-gray-800">
      <div className="grid w-full border-b border-gray-500 dark:border-gray-500 p-4 items-start gap-1">
        <div className="flex items-center gap-4">
          <img
            alt="Avatar"
            className="rounded-full bg-white"
            height="40"
            src="/bot.svg"
            style={{
              aspectRatio: "40/40",
              objectFit: "cover",
            }}
            width="40"
          />
          <div className="flex flex-col">
            <h2 className="text-base font-semibold leading-none">Joke Bot</h2>
            <p className="text-sm text-gray-500 dark:text-gray-400">
              Typing...
            </p>
          </div>
        </div>
      </div>
      <div
        ref={messageList}
        className="p-2 min-h-[50vh] max-h-[80vh] overflow-y-auto"
      >
        {messages.map((props, index) => (
          <Message key={index} {...props} />
        ))}
      </div>
      <div className="p-4 border-t border-gray-200 dark:border-gray-800">
        <form
          className="flex gap-4 items-center"
          onSubmit={(e) => {
            e.preventDefault();
            sendMessage();
          }}
        >
          <Textarea
            className="flex-1 h-[40px]"
            placeholder="Type a message..."
            onKeyUp={(e) => {
              if (e.key === "Enter") {
                sendMessage();
              }
            }}
            onChange={(e) => setCurrentMessage(e.target.value)}
            value={currentMessage}
          />
          <Button type="submit">Send</Button>
        </form>
      </div>
    </div>
  );
}

In this component:

  • We use the useMachine hook from XState to manage the bot’s state and transitions.
  • We use a useRef to select the message list element, and scroll the element whenever a new message is added to the list.
  • We use useEffect to update the messages and scroll to the bottom of the message list when the state changes.
  • We use the sendMessage function to handle user input and send appropriate events to the XState machine. Notice that we don’t really care about the current state. The transition will only occur if the state machine is in a state where it can accept the action to trigger the transition.

1. Importing necessary modules

import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Message as MessageProps } from "@/types";
import { useEffect, useRef, useState } from "react";
import { useMachine } from "@xstate/react";
import Message from "./message";
import { JokeCategory, jokeBotMachine } from "@/jokeMachine";
import { jokes } from "@/data/jokes";

This code imports the necessary modules for the Chat component. These modules include:

  • useMachine from @xstate/react
  • jokeBotMachine from @/jokeMachine
  • jokes from @/data/jokes

2. Defining default messages

const defaultMessages: MessageProps[] = [
  {
    user: "Joke Bot",
    message:
      "Hi there! I am Joke Bot! I'm here to make you laugh! Do you want to hear a joke?",
  },
];

This code defines an array of default messages that will be displayed when the chat is first loaded.

3. Creating the Chat component

export function Chat() {
  const [state, send] = useMachine(jokeBotMachine);
  const [messages, setMessages] = useState<MessageProps[]>([]);
  const [currentMessage, setCurrentMessage] = useState<string>("");

This code creates the Chat component. It uses the useMachine hook from XState to manage the bot’s state and transitions. It also uses useState to manage the messages and currentMessage.

4. Updating the message list and scrolling to the bottom

useEffect(() => {
    if (messageList.current) {
      messageList.current.scrollTop = messageList.current.scrollHeight;
    }
  }, [messages]);

This useEffect hook is used to scroll to the bottom when the messages change.

5. Updating the messages, Bot responses, based on the state

useEffect(() => {
    switch (state.value) {
      case "initial":
        setMessages([...messages, ...defaultMessages]);
        break;
      case "categories":
        setMessages([
          ...messages,
          {
            user: "Jokes Bot",
            message: "Please select a category: Pun, Dad jokes, Chuck Norris",
          },
        ]);
        break;
      case "end":
        setMessages([
          ...messages,
          {
            user: "Jokes Bot",
            message:
              "Okay Bye! If you want to hear another joke, just type hi!",
          },
        ]);
        break;
      case "jokes":
        setMessages([
          ...messages,
          {
            user: "Jokes Bot",
            message:
              "Here's a joke:\n" +
              jokes[state.context.currentCategory][
                Math.floor(
                  Math.random() * jokes[state.context.currentCategory].length
                )
              ] +
              ".\nDo you want to hear another one?",
          },
        ]);
        break;
    }
  }, [state.value]);

This useEffect hook is used to update the messages based on the current state of the XState machine.

6. Sending events to the State Machine when a message is sent by the user

const sendMessage = () => {
    setMessages([
      ...messages,
      {
        user: "Me",
        message: currentMessage,
      },
    ]);
    if (currentMessage.match(/yes/i)) {
      console.log("Yes");
      send({ type: "START" });
      send({ type: "NEXT_JOKE" });
    }
    if (currentMessage && currentMessage.match(/pun|dad joke|chuck norris/i)) {
      console.log("cat select");
      send({
        type: "CHOOSE_CATEGORY",
        category: currentMessage.match(
          /pun|dad joke|chuck norris/i
        )![0] as JokeCategory,
      });
    }
    if (
      currentMessage.trim().match(/^no$/) ||
      currentMessage.match(/nope|end|bye|quit|stop|exit/i)
    ) {
      console.log("stop");
      send({ type: "END" });
    }
    if (currentMessage.match(/restart|hi|hello|new|init/i)) {
      console.log("restart");
      send({ type: "RESTART" });
    }
    console.log(state.value);
    setCurrentMessage("");
  };

This sendMessage function is called when the user sends a message. It adds the user’s message to the message list and sends an event to the XState machine based on the user’s input.

7. Returning the Chat component

return (
    <div className="flex...">
      {/* ... */}
      <div
        ref={messageList}
      >
        {messages.map((props, index) => (
          <Message key={index} {...props} />
        ))}
      </div>
      <div >
        <form
          onSubmit={(e) => {
            e.preventDefault();
            sendMessage();
          }}
        >
          <Textarea
            placeholder="Type a message..."
            onKeyUp={(e) => {
              if (e.key === "Enter") {
                sendMessage();
              }
            }}
            onChange={(e) => setCurrentMessage(e.target.value)}
            value={currentMessage}
          />
          <Button type="submit">Send</Button>
        </form>
      </div>
    </div>
  );

This code returns the Chat component. The component includes a chat interface with a message list and a form for sending messages.

Running the Joke Bot

Now, let’s run our joke bot:

npm run dev

This will start a development server on your local machine. You can now open your browser and go to http://localhost:5173  to see the joke bot in action.

Conclusion

In this blog post, we built a joke bot using XState. We learned how to use XState to manage the bot’s state and transitions, and how to handle user input and respond with jokes. We also learned how to use React and XState together to create a state machine combined with a UI to drive it with user inputs.

Previous
Working with pixels – Bitmaps encoding, decoding, manipulating, and everything in between
Next
Code Katas, why, what, and how?
© 2024 Anil Maharjan