How State Machines Saved our Bacon 🥓

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 its a great way to author libraries and who doesnt like some TypeScript. This process went well. I was enjoying the benefits TypeScript gives you.

Heres a quick explanation of the 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: () => {},
});

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. recieve a sellable from props
  2. instantiate initial state
  3. fetch current price the sellable
  4. check for an applied coupon
  5. extract available coupons
  6. extract purchase power parity (PPP) coupon
  7. create function to handle purchase complete
  8. create function for when coupon is applied
  9. return display price, functions, and other relevent data

The main areas being: load the most recent price, handle any available coupons, give the user of the hook information about everything thats 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])
}

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,
})

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: {},
},
)

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)
}

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]
},
},
}

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',
],
},

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 seperate action because I like to see all the different things that are happening when I read the machine. You could techinially 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']},
],
},

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 to another state. The first condition to return true will be the transition thats 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
},
},

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,
}),

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,
}),
],
},
},
},

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,
}),

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: {},
},

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 recieve 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,
}),
],
},
},
},

We can either cancel the purchase and return to priceLoaded or the user has entered their data and are 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
},
}),
},
},
},

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 myself. 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 didnt expect. I can go to my machine and know exactly where coupons get applied to check if its applied correctly and excatly where the coupon is being used in the request. Theres no guessing involved.

As feature requests come in, its 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,
})

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,
// ...
},
// ...
)

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},
},
},

Theres 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.