DEV Community

loading...
egghead.io

How xstate saved our šŸ„“

Ian Jones
I build software on the web.
Originally published at ianjones.us 惻9 min read

useEffect Overload

egghead is the CMS, sale provider, and authentication for what we call Wes Bos as a Service (WBaaS) sites. This includes TestingJavaScript.com, PureReact.com, and now EpicReact.dev.

When we set out to build EpicReact.dev, we knew what we were doing. We've built 2 of these sites before and had handled authentication and purchasing. I figured it was time to extract the purchasing code out of these sites into its own package.

I used [[tsdx]] because it's a great way to author libraries and who doesn't like some [[TypeScript]]. This process went well. I was enjoying the benefits TypeScript gives you.

Heres a quick explanation of the commerce package. It was one big useReducer. I had state that relied on other elements of my state, and as Kent says, this is the time to useReducer.

The api of our hook was like this:

const {
    notification,
    parityCoupon,
    countryName,
    onApplyParityCoupon,
    displayParityCouponOffer,
    error,
    onPurchaseComplete,
    displayPrice,
    seriesPackageLoading,
    showClaimCoupon,
    purchasing,
    clearNotifications,
    appliedCoupon,
  } = usePackage({
    sellable,
    quantity: 1,
    authToken: () => {},
  });
Enter fullscreen mode Exit fullscreen mode

You can tell that theres a lot going on under the hood. We passed a sellable, quantity, and authToken to the hook. A sellable is something that has a price and a url on the object to check that price along with a url to make the purchase.

For the internals, heres the stack of hooks that I ended up with:

  • useAvailableCoupons.tsx
  • useCouponFromHeader.tsx
  • useDefaultCoupon.tsx
  • useFetchPackageForSellable.tsx
  • usePackage.tsx
  • usePackageCheckout.tsx
  • usePackageReducer.tsx
  • useParityCoupon.tsx
  • useRequest.tsx

usePackage is the hook that orchestrated everything. The basic flow was:

  1. receive a sellable from props
  2. instantiate initial state
  3. fetch the current price of the sellable
  4. check for an applied coupon
  5. extract available coupons
  6. extract purchase power parity (PPP) coupon
  7. create a function to handle purchase complete
  8. create a function for when the coupon is applied
  9. return display price, functions, and other relevant data

The main areas being: load the most recent price, handle any available coupons, give the user of the hook information about everything that's happening.

Most of these hooks are use effects waiting for changes of the particular state they manage. Lets take a look at the useParityCoupon hook:

const useParityCoupon = (
  dispatch: DispatchType,
  availableCoupons?: CouponType[],
) => {
  React.useEffect(() => {
    const parityCoupon = find(availableCoupons, {
      coupon_region_restricted: true,
    })
    if (parityCoupon) {
      const countryCode = get(parityCoupon, 'coupon_region_restricted_to')
      const countryName = get(parityCoupon, 'coupon_region_restricted_to_name')
      const displayParityCouponOffer = !(
        isEmpty(countryName) ||
        isEmpty(countryCode) ||
        isEmpty(parityCoupon)
      )

      dispatch({
        type: SET_PARITY_COUPON,
        displayParityCouponOffer,
        parityCoupon,
        countryName,
      })
    }
  }, [availableCoupons])
}
Enter fullscreen mode Exit fullscreen mode

You may notice one of the bugs that was in our purchase flow. availableCoupons is of type CouponType[] which is not a stable value. React will shallowly compare this reference. When this hook runs again, availableCoupons will always be different. These hooks were scattered with issues like this. This is one of the foot guns that made debugging these hooks difficult.

I ran into issues testing this code in EpicReact. One being, the parity coupon was not being set when it should have been. When the value you expect to be there isn't, you have to go inspect what could be affecting it. In this case, I had to inspect these hooks:

  • usePackage.tsx
  • useFetchPackageForSellable.tsx
  • useAvailableCoupons.tsx
  • usePackageReducer.tsx
  • useParityCoupon.tsx

Tracing the data through all of these hooks is a nightmare. First you check if usePackage is calling useParityCoupon correctly. Next, we have to check if the values from useFetchPackageForSellable are setting state in usePackageReducer correctly. Then I had to make sure that useAvailableCoupons set the coupons correctly and finally that useParityCoupon was sending the correct event when it was supposed to. This took a lot of debugger and console.log statements to just figure out what the flow of data was.

On top of this, we have to make sure that when the user applies the PPP coupon, we refetch the price all over again.

All of this had to be stored in my head before I could start making any changes.

XState Saves the Day

One of the first things you will notice when using the XState version of this hook is how much simpler the api is:

const [state, send] = useCommerceMachine({
   sellable: bundle,
 })
Enter fullscreen mode Exit fullscreen mode

XState just needs the sellable to kick off the price checking process.

XState forces you to think about your discreet states. Theres a big difference between the context you have around your state machine and the states your machine can be in.

Everything I described above can be boiled down into these states:

  • fetchingPrice
  • priceLoaded
  • startingPurchase
  • handlingPurchase
  • success
  • failure

We use these states to build up the context of our state machine. This is what we want to track in our state machine:

  • sellable
  • purchaseHeaders
  • error: null
  • price: null
  • appliedCoupon: null
  • notification: null
  • email: null
  • stripeToken: null
  • quantity: 1
  • purchase: null

As you can see sellable and purchaseHeaders are all passed in from a closure above. Heres what the basic state machine with no transitions looks like:

const createCommerceMachine = ({
  sellable,
  purchaseHeaders,
}) =>
  createMachine(
    {
      id: 'commerceMachine',
      initial: 'fetchingPrice',
      context: {
        sellable,
        purchaseHeaders,
        error: null,
        price: null,
        appliedCoupon: null,
        notification: null,
        email: null,
        stripeToken: null,
        quantity: 1,
        purchase: null,
      },
      states: {
        fetchingPrice: {},
        checkingPriceData: {},
        priceLoaded: {},
        startingPurchase: {},
        handlingPurchase: {},
        success: {},
        failure: {},
      },
    },
    {
      guards: {},
      actions: {},
    },
  )
Enter fullscreen mode Exit fullscreen mode

You may notice that this createCommerceMachine function takes more arguments than our useCommerceMachine hook and thats because we create an intermediate hook to load authentication and such:

export const useCommerceMachine = ({sellable}) => {
  const {user, authToken} = useEggheadUser()
  const sellableSlug = get(sellable, 'slug')
  const userId = get(user, 'id')
  const commerceMachine = React.useMemo(() => {
    const purchaseHeaders = authToken()
      ? {Authorization: `Bearer ${authToken()}`}
      : {}
    return createCommerceMachine({
      sellable,
      purchaseHeaders,
      stripeToken: process.env.STRIPE_TOKEN,
    })
  }, [sellableSlug, userId])

  return useMachine(commerceMachine)
}
Enter fullscreen mode Exit fullscreen mode

We are memoizing our call to createCommerceMachine because we only want to create a new machine if the sellableSlug or the userID has changed.

The first machine initializes in the fetchingPrice state. This is a state that is invoking a promise (xstate docs). A state can invoke a number of services but in our case we are using a promise. Heres the overview of the state:

fetchingPrice: {
    invoke: {
        id: 'fetchPrice',
        src: (context, event) => {// return a promise here},
        onDone: {
          target: 'checkingPriceData',
          actions: [// do something with the resulting data],
        },
        onError: {
          target: 'failure',
          actions: [// do something if the promise throws an error]
        },
    },
}
Enter fullscreen mode Exit fullscreen mode

You can see that invoke takes a src promise. XState will handle calling this function and handing the resulting data to onDone or onError. This is where we calculate the context.price object.

onDone: {
  target: 'checkingPriceData',
  actions: [
    assign({
      price: (context, event) => event.data[0],
    }),
    'adjustPriceForUpgrade',
  ],
},
Enter fullscreen mode Exit fullscreen mode

We use XState's assign function to take the data that the event gave back and assign it to the price. We have to handle a case where we adjust the price if the user is upgrading a purchase. I do this in separate action because I like to see all the different things that are happening when I read the machine. You could technically do this action in the assign above, but then you aren't optimizing for deletion.

The next state is checkingPriceData:

checkingPriceData: {
  always: [
    {
      target: 'failure',
      cond: 'couponErrorIsPresent',
      actions: ['setErrorFromCoupon'],
    },
    {target: 'priceLoaded', actions: ['checkForDefaultCoupon']},
  ],
},
Enter fullscreen mode Exit fullscreen mode

This is a transient state. We use always to make a transient transition (xstate docs). This is a spot in our state machine where if some condition is true with the current context, we want to conditionally send it to another state. The first condition to return true will be the transition that's executed. The default is to send to priceLoaded state because there is no condition preventing this from happening.

We defined our couponErrorIsPresent guard in our guards object below.

guards: {
    couponErrorIsPresent: (context, event) => {
      return context?.price?.coupon_error
    },
},
Enter fullscreen mode Exit fullscreen mode

All it needs to do is return true or false. We check the specific data we need to see if a coupon_error is present. If it is, we use setErrorFromCoupon to set the error context:

setErrorFromCoupon: assign({
  error: (context, event) => context.price.price_message,
}),
Enter fullscreen mode Exit fullscreen mode

This allows our UI to check the state of our machine and our context to determine if it needs to display an error.

Moving along, we assume that our price check didnt return a coupon error, we move into the priceLoaded state. This is the last state our machine will automatically transition to until it receives an event from the outside telling to to transition to another state. Heres everything the outside world can tell our state machine to do:

priceLoaded: {
  on: {
    APPLY_COUPON: {
      target: 'fetchingPrice',
      actions: [
        assign({
          appliedCoupon: (context, event) => event.appliedCoupon,
        }),
      ],
    },
    DISMISS_COUPON: {
      target: 'fetchingPrice',
      actions: [
        assign({
          appliedCoupon: null,
        }),
      ],
    },
    SET_QUANTITY: {
      target: 'fetchingPrice',
      actions: [
        assign({
          quantity: (context, event) => event.quantity,
          appliedCoupon: null,
        }),
      ],
    },
    START_PURCHASE: {
      target: 'startingPurchase',
    },
    CLAIM_COUPON: {
      target: 'handlingPurchase',
      actions: [
        assign({
          email: (context, event) => event.email,
        }),
      ],
    },
  },
},

Enter fullscreen mode Exit fullscreen mode

You'll notice that APPLY_COUPON, DISMISS_COUPON, and SET_QUANTITY all just send the machine back to the fetchingPrice state. This is one of the benefites of XState. We can reuse our logic on how we fetch the price but give it a slightly different context.

Say our machine recieves the APPLY_COUPON event. This event comes with appliedCoupon. You can see that we are using assign to add the appliedCoupon from the event into our context:

assign({
  appliedCoupon: (context, event) => event.appliedCoupon,
}),
Enter fullscreen mode Exit fullscreen mode

Then our machine transitions back into the fetchingPrice state. I mentioned earlier that fetchingPrice invokes a promise for us. Heres what the promise looks like:

fetchingPrice: {
  invoke: {
    id: 'fetchPrice',
    src: (context, event) => {
      const {
        quantity,
        appliedCoupon,
        sellable,
      } = context
      const {
        priceCheckURL,
        site,
        egghead_id: sellable_id,
        type,
      } = sellable
      return axios
        .post(
          priceCheckURL,
          pickBy({
            sellables: [
              {
                site,
                sellable_id,
                sellable: type.toLowerCase(),
                quantity,
              },
            ],
            site,
            code: appliedCoupon,
          }),
        )
        .then(({data}) => data)
    },
    onDone: {},
    onError: {},
  },
Enter fullscreen mode Exit fullscreen mode

You can see that we are grabbing quantity, appliedCoupon, sellable, and upgradeFromSellable from our context and passing some of those values to the body of our axios.post call. This is how we can reuse our fetchingPrice state, with different context to fetch prices when no coupon is applied, when we've applied a coupon, or even when the quantity we are asking for has changed.

When the user wants to start a purchase, we receive a START_PURCHASE event. This event simply transitions us to the startingPurchase state. We have this state so that we know when the user has clicked the "Purchase" button and a modal to accept their info is been created.

While in the startingPurchase state, we can do two things:

startingPurchase: {
  on: {
    CANCEL_PURCHASE: {
      target: 'priceLoaded',
    },
    HANDLE_PURCHASE: {
      target: 'handlingPurchase',
      actions: [
        assign({
          email: (context, event) => event.email,
          stripeToken: (context, event) => event.stripeToken,
        }),
      ],
    },
  },
},
Enter fullscreen mode Exit fullscreen mode

We can either cancel the purchase and return to priceLoaded or the user has entered their data and is attempting a purchase that we need to handle. In our case, we contact stripe for a token and then get the email that they entered. This is all we need to kick off our purchase process.

handlingPurchase is a state that invokes a promise to POST data to our purchases api endpoint:

handlePurchase: {
  invoke: {
    id: 'handlePurchase',
    src: (context, event) => {// return promise that makes the purchase},
    onDone: {
      target: 'success',
      actions: [
        assign({
          purchase: (context, event) => event.data,
        }),
        'sendToThanks',
      ],
    },
    onError: {
      target: 'failure',
      actions: assign({
        error: (context, event) => {
          return event?.data?.response?.data?.error
        },
      }),
    },
  },
},
Enter fullscreen mode Exit fullscreen mode

This is the same process we've described. We can either transition to success or failure based on the response of the purchase promise. If the purchase was successful, our specific UX is that we send the user to a /thanks page.

State machines are verbose. I haven't described every feature this machine does but in total, this file is 314 lines long. XState forces you to map our all our states and transitions explicitly. This affords you the ability to know exactly when something is happening.

Earlier, when I had a problem with my custom usePackage hook, I would have to follow all the hook calls to track the data and when things happened. In this case, say I am trying to apply a coupon but my machine loads the price, and the request comes back with a price I didn't expect. I can go to my machine and know exactly where coupons get applied to check if it's applied correctly and exactly where the coupon is being used in the request. There's no guessing involved.

As feature requests come in, it's much easier to know exactly where they fit in. Say we want to add a feature so that the user can upgrade from one package to another. We need to send the package we are upgrading from to the server. We know we'll need to pass that package in from react:

const [state, send] = useCommerceMachine({
   sellable: bundle,
   upgradeFromSellable,
 })
Enter fullscreen mode Exit fullscreen mode

Then we know that we will need this object in our context inside of our machine so we can use it when we are fetching our price.

const createCommerceMachine = ({
  sellable,
  purchaseHeaders,
  upgradeFromSellable,
}) =>
  createMachine(
    {
      id: 'commerceMachine',
      initial: 'fetchingPrice',
      context: {
        sellable,
        purchaseHeaders,
        upgradeFromSellable,
       // ...
      },
      // ...
)
Enter fullscreen mode Exit fullscreen mode

Now we can use this upgradeFromSellable object in our fetchingPrice promise.

fetchingPrice: {
  invoke: {
    id: 'fetchPrice',
    src: (context, event) => {
      const {
        sellable,
        upgradeFromSellable,
        // ...
      } = context
      const {
        // ...
      } = sellable
      return axios
        .post(
          priceCheckURL,
          pickBy({
            sellables: [
              {
                upgrade_from_sellable_id: upgradeFromSellable?.slug,
                upgrade_from_sellable: upgradeFromSellable?.type,
                // ...
              },
            ],
            // ...
          }),
        )
        .then(({data}) => data)
    },
    onDone: {// assign the returned price},
    onError: {// assign the error},
  },
},
Enter fullscreen mode Exit fullscreen mode

There's no guessing involved in where we need to put this object to affect our prices call.

There is always state our UI's are dealing with, even if we are explicitly modeling it. State machines force you to model how you want your state to act and what can change the state in your machine. State machines expose the implicit state that you may or may not have known was there.

Discussion (2)

Collapse
zchbndcc9 profile image
Zach Banducci • Edited

Really great article! I love to see XState being used out in the wild.

One thing you should probably note is that the machine input into useMachine is only interpreted once and is not reactive to any configuration changes. This means that even though you input a machine with different configuration, the result from useMachine wonā€™t have your updated sellable or purchaseHeaders(I learned this the hard way).

Instead, you can use two useEffects to listen for when sellableSlug and userId change and send explicit CHANGE events to your interpreted machine. This may also help make things more predictable.

This may have been resolved from the last time I inspected the source, so take my suggestion with the smallest grain of salt.

Otherwise, this looks really awesome!

Collapse
davidkpiano profile image
David K. šŸŽ¹

You're correct - the way state machines work is via events, and events only, which makes them much more predictable than a machine arbitrarily "changing itself" at any time.