DEV Community

Cover image for Basic form flow using xstate and react
Maher Alkendi
Maher Alkendi

Posted on

Basic form flow using xstate and react

I am on a journey to learn how to use xstate. As I learn, I decided to share pieces of my learnings. The goal is to increase my understanding and to add more xstate implementation examples to the ecosystem.

The app we will be building is a simple form that logs the submitted text to the console. This sounds super simple and can easily be implemented with react. However, how would we build this using xstate?

Lets start by building the UI components of our app. Note that I am using CodeSandbox to write my code.

import React from "react";
import ReactDOM from "react-dom";

import "./styles.css";

function App() {
  return (
    <div className="App">
      <h1>Hello World!</h1>
      <h2>Submit to log some text on the console</h2>
      <form>
        <input
          type="text"
          placeholder="Enter text"
        />
        <button>submit</button>
      </form>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

This is what our app looks like in the browser:

Alt Text

Alright, lets install xstate and @xstate/react as dependencies in our application. Then import them and start thinking about our simple form State Machine

...
import { Machine } from "xstate";

const simpleFormMachine = Machine(
  {
    id: "simpleForm",
    initial: "idle",
    states: {
      idle: {},
      submitting: {}
      }
    }
  }
);

...

As a start we give our machine an unique id and an initial state. Next we enter our expected states.

This is the flow we are expecting: the form is idle then the user starts an event, which is the "typing" event. In the "typing" event the form is still in idle state. Then when the user presses the submit button, the form starts submitting the data. That is when we should implement the logging logic.

Based on the above narrative I saw fit we have two states for our form: idle state and submitting state.

We have a basic idea of what our state machine will look like. However, where can we store the state of the input value? There are infinite representations of this state (whatever the user will input), which should lead us to conclude that we need to use the xstate extended state, named context. Then we can pass this value to our jsx input via the @xstate/react library.

import React from "react";
import ReactDOM from "react-dom";
import { Machine } from "xstate";
import { useMachine } from "@xstate/react";

import "./styles.css";

const simpleFormMachine = Machine(
  {
    id: "simpleForm",
    initial: "idle",
    context: {
      inputValue: ""
    },
    states: {
      idle: {},
      submitting: {}
  }
);

function App() {
  const [current, send] = useMachine(simpleFormMachine);
  return (
    <div className="App">
      <h1>Hello World!</h1>
      <h2>Submit to log some text on the console</h2>
      <form>
        <input
          type="text"
          placeholder="Enter text"
          value={current.context.inputValue}
        />
        <button>submit</button>
      </form>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

To test that our input value is connected to our state machine's context, simply change the initial value of inputValue and you will see the change reflected in the input.

Next lets implement onChange using xstate. When the user starts inserting text in the input box the onChange event is triggered. When this happens we should send a message to the state machine telling it that the user is currently typing. Our machine should then implement an action to assign our context to the value being inputted by the user.

...

const simpleFormMachine = Machine(
  {
    id: "simpleForm",
    initial: "idle",
    context: {
      inputValue: ""
    },
    states: {
      idle: {
        on: {
          TYPING: {
            actions: "typing"
          }
        }
      },
      submitting: {}
    }
  },
  {
    actions: {
      typing: assign((ctx, e) => ({ inputValue: e.value }))
    }
  }
);

function App() {
  const [current, send] = useMachine(simpleFormMachine);
  return (
    <div className="App">
      <h1>Hello World!</h1>
      <h2>Submit to log some text on the console</h2>
      <form>
        <input
          type="text"
          placeholder="Enter text"
          value={current.context.inputValue}
          onChange={e =>
            send({
              type: "TYPING",
              value: e.target.value
            })
          }
        />
        <button>submit</button>
      </form>
    </div>
  );
}

...

Now lets implement our submit logic. This happens when the form is submitted, via the submit button or the enter key. In this case we want our form to send an event indicating it is submitting. Our machine will then change from idle to submitting state. As we enter this state we should log the current context value to the console.

...

const simpleFormMachine = Machine(
  {
    id: "simpleForm",
    initial: "idle",
    context: {
      inputValue: ""
    },
    states: {
      idle: {
        on: {
          TYPING: {
            actions: "typing"
          },
          SUBMIT: "submitting"
        }
      },
      submitting: {
        entry: "log"
      }
    }
  },
  {
    actions: {
      typing: assign((ctx, e) => ({ inputValue: e.value })),
      log: (ctx, e) => console.log(ctx.inputValue)
    }
  }
);

function App() {
  const [current, send] = useMachine(simpleFormMachine);
  return (
    <div className="App">
      <h1>Hello World!</h1>
      <h2>Submit to log some text on the console</h2>
      <form
        onSubmit={e => {
          e.preventDefault();
          send("SUBMIT");
        }}
      >
        <input
          type="text"
          placeholder="Enter text"
          value={current.context.inputValue}
          onChange={e =>
            send({
              type: "TYPING",
              value: e.target.value
            })
          }
        />
        <button>submit</button>
      </form>
    </div>
  );
}
...

The app is almost complete. However, the only issue we have is that we are not going back to an idle state after submitting the form. Lets send a second event after the 'SUBMIT' event. Lets call it 'STOPPED_TYPING'. when this event occurs we will go back to the idle state and we should trigger an action to clear the form.

...

const simpleFormMachine = Machine(
  {
    id: "simpleForm",
    initial: "idle",
    context: {
      inputValue: ""
    },
    states: {
      idle: {
        on: {
          TYPING: {
            actions: "typing"
          },
          SUBMIT: "submitting"
        }
      },
      submitting: {
        entry: "log",
        on: {
          STOPPED_TYPING: {
            target: "idle",
            actions: "clear"
          }
        }
      }
    }
  },
  {
    actions: {
      typing: assign((ctx, e) => ({ inputValue: e.value })),
      log: (ctx, e) => console.log(ctx.inputValue),
      clear: assign((ctx, e) => ({ inputValue: "" }))
    }
  }
);

function App() {
  const [current, send] = useMachine(simpleFormMachine);
  return (
    <div className="App">
      <h1>Hello World!</h1>
      <h2>Submit to log some text on the console</h2>
      <form
        onSubmit={e => {
          e.preventDefault();
          send("SUBMIT");
          send("STOPPED_TYPING");
        }}
      >
        <input
          type="text"
          placeholder="Enter text"
          value={current.context.inputValue}
          onChange={e =>
            send({
              type: "TYPING",
              value: e.target.value
            })
          }
        />
        <button>submit</button>
      </form>
    </div>
  );
}

...

For the full code check out the codeSandbox below:

See below for the final state chart:

Alt Text

Did I miss anything? Got a better way of doing this? leave a comment! :]

Ok! Now back to learning 👨🏿‍💻

Oldest comments (0)