DEV Community

Cover image for Rsvp to weddings with XState
Andrew Petersen
Andrew Petersen

Posted on

Rsvp to weddings with XState

I am building the RSVP form for my wedding website and I want allow guests to look themselves up based on their street number.

Happy Path
On the wedding site, the happy path is something like this:

Happy Path

  1. Ask for the Street Number
  2. Perform the lookupGuest API call
  3. When a guest is found by their street number, display the RSVP form
  4. Guest fills out and submits the RSVP form
  5. POST to the submitRsvp endpoint
  6. Display a thank you message

Things seems pretty easy! I should be able to knock it out in an evening. But wait....

Complexities

  • What if we don't find a guest by street number?
  • If a guest has already submitted the RSVP, then they:
    • should see how they previously responded.
    • shouldn't be able to submit again.
  • Street number isn't guaranteed to be unique because we sent multiple invitations to the same address.
  • What if any of those API calls fail?

State Machines to the rescue!

In this walkthrough, I'll solve those complexities and more with an XState machine.

DavidKPiano has single handedly put state machines on the map in the front end community (I don't think he gets enough credit for it). Every time I consume his content I think, "Whoa! why isn't everyone doing this?!"
However, in practice I've reached for them a few times, and it always goes like this:

  1. It takes me a while to remember how to shift my thinking (I get set in my imperative ways). Then it takes me a little bit to look up the syntax.
  2. Once I do though, I LOVE it! It is so clean and maintainable.
  3. But then, I go off onto another project that isn't using them and forget everything again.

State machines and XState don't have to be complicated monsters that require a CompSci PHD to wrangle. If you learn just the easiest 10%, you can solve 90% of your problems.

I'm writing this post to help cement my state machine habits, and to serve as a quick reference.

Define your states

First think through all the different states your UI could be in. For the RSVP scenario I'll have:

States

  1. unknown - This is where I'll ask the guest to look themselves up by street number
  2. finding - This will show a loading indicator while waiting for the /lookupGuest api call
  3. choosing - This is where I'll show the guest a list of guests who match the entered street number.
  4. checkingRsvp - This is a "transient" state. It's a router. Once a guest is chosen, it'll instantly check to see if that guest has already rsvp'd and route to responded or unresponded
  5. unresponded - This will show the RSVP form
  6. responded - This will show a readonly view of how the guest RSVPd. This is the last and final step.

Here is how you'd represent that with XState

const rsvpMachine = Machine({
  id: 'rsvp',
  initial: 'unknown',
  context: { },
  states: {
    unknown: {},
    finding: {},
    choosing: {},
    checkingRsvp: {},
    unresponded: {},
    submitting: {},
    responded: {
      type: "final"
    },
  }
});
Enter fullscreen mode Exit fullscreen mode

If you want to follow along, try pasting this state chart into the XState Visualizer. I built the entire thing this way first, then copy/pasted it into my project.

Define the context

What data needs to stick around between states?

In my case, it will be the guest lookup results, and the chosen guest. I'll set them both to null to start. In an upcoming step, the state machine will pass the context to functions like checkHasResponded to decide which state to transition to.

const checkHasResponded = (context) => context.guest && context.guest.rsvp;
const checkHasNotResponded = (context) => context.guest && !context.guest.rsvp;
const checkAlreadyChosen = (context) => context.guest;

const rsvpMachine = Machine({
  id: 'rsvp',
  initial: 'unknown',
  context: {
    results: null,
    guest: null,
  },
  ...
});
Enter fullscreen mode Exit fullscreen mode

Define the user driven events

For each state, what activities can the the user perform?

For example, you can FIND when in the unknown state, but you CAN'T FIND when in the submitting state.

State Chart with Events

  1. When in the unknown state, a guest can FIND themselves by street number, and it should send them to the finding state
  2. When in the choosing state, a guest can CHOOSE which lookup result is them, and it should send them to the checkingRsvp state.
  3. Entering the checkingRsvp should automatically route to the responded or unresponded state.
  4. When in the unresponded state a guest can SUBMIT their RSVP, transitioning them to the submitting state

There are 2 noticeable gaps in the state chart:

  • How do you get from finding to choosing ?
  • How do you get from submitting to responded?
  • Both of these are tied to API calls instead of an explicit user interaction.
  • I'll cover this in the next step.

Here is the full state machine so far. The events described above are setup with the on property.

The interesting one is checkingRsvp. There the event key is blank, which means it will automatically fire. Then, the blank event key is passed multiple targets, each with a condition so it can route accordingly. XState calls this a transient transition.

const checkHasResponded = (context) => context.guest && context.guest.rsvp;
const checkHasNotResponded = (context) => context.guest && !context.guest.rsvp;
const checkAlreadyChosen = (context) => context.guest;

const rsvpMachine = Machine({
  id: "rsvp",
  initial: "unknown",
  context: {
    results: null,
    guest: null,
  },
  states: {
    unknown: {
      on: {
        FIND: "finding",
      },
    },
    finding: {},
    choosing: {
      on: {
        CHOOSE: "checkingRsvp",
      },
    },
    checkingRsvp: {
      on: {
        "": [
          {
            target: "unresponded",
            cond: checkHasNotResponded,
          },
          {
            target: "responded",
            cond: checkHasResponded,
          },
        ],
      },
    },
    unresponded: {
      on: {
        SUBMIT: "submitting",
      },
    },
    submitting: {},
    responded: {
      type: "final",
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Invoking services

The last big piece is figuring out how to make an API call when entering the finding or the submitting state. This is done via XState's invoke property.

To setup an invoke for for the finding state:

  1. Use invoke.src to call an async function, lookupGuest
  2. Setup onDone.target to transition to next state when the async call completes
  3. Setup onDone.actions to assign the async result (found in event.data ) onto the context
    • XState handles taking the result of the async function and putting it onto event.data
const rsvpMachine = Machine({
  ...
  states: {
    ...
    finding: {
      invoke: {
        id: "lookupGuest",
        // Call the async fn
        src: (context, event) => lookupGuest(event.lookupId),
        onDone: {
          // once the async call is complete 
      // move to the 'choosing' state
          target: 'choosing',
          // use xstate's assign action to update the context
          actions: assign({ 
            // store the results in context
            results: (_, event) => event.data,
            // if there was only one result, set the guest
            guest: (_, event) => event.data.length === 1 ? event.data[0] : null
          })
        }
      },
    },
    ...
  },
});
Enter fullscreen mode Exit fullscreen mode

After implementing the same kind of thing for the submitting state I was done with the RSVP state machine!

Use it in the UI

You can take a state machine like this and use XState with your framework of choice (vanilla, React, Angular, Vue etc...).

Here's an example of what a React usage might feel like. You can see the current state with state.value and you can interact with the state machine by using send to trigger state transition events.

function Rsvp() {
  const [state, send] = useMachine(rsvpMachine);

  if (state.value === "unknown") {
    return (
      <GuestLookupForm
        onSubmit={(streetNumber) =>
          send({ type: "FIND", lookupId: streetNumber })
        }
      />
    );
  }

  if (state.value === "finding") {
    return <Loading />;
  }

  if (state.value === "choosing") {
    return (
      <ChooseGuest
        guests={state.context.results}
        onSelect={(guest) => send({ type: "CHOOSE", guest})}
      />
    );
  }

  // ...You get the gist
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

It took me an hour or two to build the state chart (all in the visualizer), but once it was done the UI literally just fell into place.

So while it seems like more work up front, it is SOOO worth it! You'd end up working through these complexities regardless. Tackling the logic problems before they are muddied by UI quirks make the solutions so much cleaner and maintainable.

This also just naturally solves problems like "What if I forget to disable the submit button on click, and the user repeatedly mashes on it. Will that submit a bunch of RSVPs?"

With a state machine, the first click would transition to submitting and after that, the user can send a SUBMIT action all they want, but submitting state will just ignore it.

Final Result

Here is the final version of the State Chart, with the additional START_OVER and onError capabilities.
This was generated with David's statecharts.io Inspector
Final State Chart

Here is a codesandbox demo using the RSVP state machine in React. Take a peek at the source, machine.js, if you are curious what the final state machine code looks like.

Top comments (3)

Collapse
 
jackmellis profile image
Jack

There's no trick to state machines, they always add a fair amount of complexity.
In my experience 9/10 times you can solve a problem with a state variable/enum rather than a machine.
I often start writing a state machine, spend way too long on it, have to spend time explaining it to other devs, then realise I've over complicated things and the solution is much cleaner without a machine.
Saying all that, state machines are incredibly powerful when you need fine grained control over state transitioning. It's just I've only found a genuine need for them about 3 times in 8 years 😄

Collapse
 
droopytersen profile image
Andrew Petersen

Thanks for the feedback. That is a fair point, and my experience with them very much mirrors yours. I agree with you, it's like there is this magical "line of complexity" you'd need to cross to justify reaching for them.

However, I would argue that line feels deceptively far away due to unfamiliarity (rather than merit). Wide spread familiarity would eliminate a lot of the friction we've both experienced with State Machines (getting up to speed, explaining it, etc...).

Use a State Machine?

To compare and contrast, I think the Redux pattern is just as complex, but we have become VERY familiar with it. Familiar to the point where many (including myself) would contend it is over leveraged. For Redux, the line of complexity feels deceptively close.

All that said, your larger point that State Machines are not a silver bullet is very valid, and maybe I should have had a little blurb on when you would and wouldn't reach for them.

Collapse
 
droopytersen profile image
Andrew Petersen

Thanks @davidkpiano for all the awesome work you do for the community. I'm really excited to see what you do with the statecharts.io "Creator". That feels like the thing that could finally tip the scales towards main stream adoption.