Skip to main content
All Examples

State Machine

A checkout workflow powered by the experimental state machine module. Guards skip Shipping for digital orders, gate Place order behind a complete review, and parse promo codes into applied discounts.

State Machines
Commands
Experimental
/
import {
  Array,
  Duration,
  Effect,
  Match,
  Number,
  Option,
  Schema,
  String,
  flow,
  pipe,
} from 'effect'
import { Command, Runtime, Update } from 'foldkit'
import { Machine } from 'foldkit/experimental'
import { otherwise, to, when } from 'foldkit/experimental/machine'
import { defineMessageUnion } from 'foldkit/message'
import { defineTaggedUnion } from 'foldkit/schema'
import { evo } from 'foldkit/struct'

import { RadioGroup } from '@foldkit/ui'

// MODEL

export const Discount = Schema.Struct({
  code: Schema.String,
  percentOff: Schema.Number,
})

export const Promo = defineTaggedUnion({
  NoPromo: {},
  AppliedPromo: { discount: Discount },
  RejectedPromo: {},
})

export const CheckoutState = defineTaggedUnion({
  Cart: { isShippingRequired: Schema.Boolean },
  Shipping: { isShippingRequired: Schema.Boolean },
  Payment: {
    isPaymentMethodSelected: Schema.Boolean,
    isShippingRequired: Schema.Boolean,
  },
  Review: {
    isPaymentMethodSelected: Schema.Boolean,
    isShippingRequired: Schema.Boolean,
    isTermsAccepted: Schema.Boolean,
    promo: Promo,
    promoCodeInput: Schema.String,
  },
  Placing: {
    isShippingRequired: Schema.Boolean,
    maybeDiscount: Schema.Option(Discount),
  },
  Confirmed: {
    isShippingRequired: Schema.Boolean,
    maybeDiscount: Schema.Option(Discount),
    orderId: Schema.String,
  },
  Cancelled: { isShippingRequired: Schema.Boolean },
})

export const TransitionLogEntry = Schema.Struct({
  id: Schema.Number,
  summary: Schema.String,
})
export type TransitionLogEntry = typeof TransitionLogEntry.Type

const EDITION_RADIO_GROUP_ID = 'edition'

export const HARDCOVER_EDITION = 'Hardcover'
export const EBOOK_EDITION = 'E-book'

export const EDITIONS: ReadonlyArray<string> = [
  HARDCOVER_EDITION,
  EBOOK_EDITION,
]

export const editionName = (isShippingRequired: boolean): string =>
  isShippingRequired ? HARDCOVER_EDITION : EBOOK_EDITION

export const EditionRadioGroup = RadioGroup.create()

export const Model = Schema.Struct({
  checkout: CheckoutState,
  editionRadioGroup: RadioGroup.Model,
  transitionLog: Schema.Array(TransitionLogEntry),
  nextTransitionLogId: Schema.Number,
})
export type Model = typeof Model.Type

// MESSAGE

export const Message = defineMessageUnion({
  ClickedContinue: {},
  ClickedBack: {},
  ClickedCancel: {},
  ClickedPlaceOrder: {},
  ClickedStartOver: {},
  ToggledPaymentMethod: { isSelected: Schema.Boolean },
  SelectedEdition: { isShippingRequired: Schema.Boolean },
  GotEditionRadioGroupMessage: { message: RadioGroup.Message },
  ToggledTermsAccepted: { isAccepted: Schema.Boolean },
  UpdatedPromoCode: { value: Schema.String },
  SubmittedPromoCode: {},
  SucceededPlaceOrder: { orderId: Schema.String },
})

export type Message = typeof Message.Type

// COMMAND

const PLACE_ORDER_DELAY = Duration.seconds(1)

export const PlaceOrder = Command.define('PlaceOrder', {
  args: { isShippingRequired: Schema.Boolean },
  messages: [Message.SucceededPlaceOrder],
  execute: ({ isShippingRequired }) =>
    Effect.gen(function* () {
      yield* Effect.sleep(PLACE_ORDER_DELAY)
      return Message.SucceededPlaceOrder({
        orderId: isShippingRequired ? 'SHIP-1001' : 'DIGI-1001',
      })
    }),
})

// MACHINE

const PROMO_DISCOUNTS: ReadonlyArray<typeof Discount.Type> = [
  { code: 'READER10', percentOff: 10 },
  { code: 'SIGNAL20', percentOff: 20 },
]

export const promoToMaybeDiscount = (
  promo: typeof Promo.Type,
): Option.Option<typeof Discount.Type> =>
  Match.value(promo).pipe(
    Match.tags({ AppliedPromo: appliedPromo => appliedPromo.discount }),
    Match.option,
  )

export const isReviewReady = (
  review: typeof CheckoutState.Review.Type,
): boolean => review.isPaymentMethodSelected && review.isTermsAccepted

export const reviewToMaybeDiscount = (
  review: typeof CheckoutState.Review.Type,
): Option.Option<typeof Discount.Type> => {
  const normalizedCode = pipe(
    review.promoCodeInput,
    String.trim,
    String.toUpperCase,
  )

  return Array.findFirst(
    PROMO_DISCOUNTS,
    discount => discount.code === normalizedCode,
  )
}

export const checkoutMachine = Machine.define({
  state: CheckoutState,
  message: Message,
})({
  initial: CheckoutState.Cart({ isShippingRequired: true }),
  shared: [
    Machine.forStates(['Cart', 'Shipping', 'Payment', 'Review']).on({
      ClickedCancel: to('Cancelled', ({ state }) => ({
        model: CheckoutState.Cancelled({
          isShippingRequired: state.isShippingRequired,
        }),
      })),
    }),
    Machine.forStates(['Confirmed', 'Cancelled']).on({
      ClickedStartOver: to('Cart', ({ state }) => ({
        model: CheckoutState.Cart({
          isShippingRequired: state.isShippingRequired,
        }),
      })),
    }),
  ],
  states: {
    Cart: {
      on: {
        SelectedEdition: to('Cart', ({ state, message }) => ({
          model: evo(state, {
            isShippingRequired: () => message.isShippingRequired,
          }),
        })),
        ClickedContinue: [
          when(
            state => state.isShippingRequired,
            'Shipping',
            ({ state }) => ({
              model: CheckoutState.Shipping({
                isShippingRequired: state.isShippingRequired,
              }),
            }),
          ),
          otherwise(
            to('Payment', ({ state }) => ({
              model: CheckoutState.Payment({
                isPaymentMethodSelected: false,
                isShippingRequired: state.isShippingRequired,
              }),
            })),
          ),
        ],
      },
    },
    Shipping: {
      on: {
        ClickedContinue: to('Payment', ({ state }) => ({
          model: CheckoutState.Payment({
            isPaymentMethodSelected: false,
            isShippingRequired: state.isShippingRequired,
          }),
        })),
        ClickedBack: to('Cart', ({ state }) => ({
          model: CheckoutState.Cart({
            isShippingRequired: state.isShippingRequired,
          }),
        })),
      },
    },
    Payment: {
      on: {
        ToggledPaymentMethod: to('Payment', ({ state, message }) => ({
          model: evo(state, {
            isPaymentMethodSelected: () => message.isSelected,
          }),
        })),
        ClickedContinue: to('Review', ({ state }) => ({
          model: CheckoutState.Review({
            isPaymentMethodSelected: state.isPaymentMethodSelected,
            isShippingRequired: state.isShippingRequired,
            isTermsAccepted: false,
            promo: Promo.NoPromo(),
            promoCodeInput: '',
          }),
        })),
        ClickedBack: [
          when(
            state => state.isShippingRequired,
            'Shipping',
            ({ state }) => ({
              model: CheckoutState.Shipping({
                isShippingRequired: state.isShippingRequired,
              }),
            }),
          ),
          otherwise(
            to('Cart', ({ state }) => ({
              model: CheckoutState.Cart({
                isShippingRequired: state.isShippingRequired,
              }),
            })),
          ),
        ],
      },
    },
    Review: {
      on: {
        ToggledPaymentMethod: to('Review', ({ state, message }) => ({
          model: evo(state, {
            isPaymentMethodSelected: () => message.isSelected,
          }),
        })),
        ToggledTermsAccepted: to('Review', ({ state, message }) => ({
          model: evo(state, { isTermsAccepted: () => message.isAccepted }),
        })),
        UpdatedPromoCode: to('Review', ({ state, message }) => ({
          model: evo(state, {
            promoCodeInput: () => message.value,
            promo: currentPromo =>
              currentPromo._tag === 'RejectedPromo'
                ? Promo.NoPromo()
                : currentPromo,
          }),
        })),
        SubmittedPromoCode: [
          when(
            reviewToMaybeDiscount,
            'Review',
            ({ state, guardValue: discount }) => ({
              model: evo(state, {
                promo: () => Promo.AppliedPromo({ discount }),
              }),
            }),
          ),
          otherwise(
            to('Review', ({ state }) => ({
              model: evo(state, { promo: () => Promo.RejectedPromo() }),
            })),
          ),
        ],
        ClickedPlaceOrder: [
          when(isReviewReady, 'Placing', ({ state }) => ({
            model: CheckoutState.Placing({
              isShippingRequired: state.isShippingRequired,
              maybeDiscount: promoToMaybeDiscount(state.promo),
            }),
            commands: [
              PlaceOrder({ isShippingRequired: state.isShippingRequired }),
            ],
          })),
        ],
        ClickedBack: to('Payment', ({ state }) => ({
          model: CheckoutState.Payment({
            isPaymentMethodSelected: state.isPaymentMethodSelected,
            isShippingRequired: state.isShippingRequired,
          }),
        })),
      },
    },
    Placing: {
      on: {
        SucceededPlaceOrder: to('Confirmed', ({ state, message }) => ({
          model: CheckoutState.Confirmed({
            isShippingRequired: state.isShippingRequired,
            maybeDiscount: state.maybeDiscount,
            orderId: message.orderId,
          }),
        })),
      },
    },
  },
})

// INIT

export const initialModel = Model.make({
  checkout: checkoutMachine.initial,
  editionRadioGroup: RadioGroup.init({ id: EDITION_RADIO_GROUP_ID }),
  transitionLog: [],
  nextTransitionLogId: 0,
})

export const init: Runtime.ApplicationInit<Model, Message> = () => ({
  model: initialModel,
})

// UPDATE

type UpdateReturn = Update.Return<Model, Message>

export const TRANSITION_LOG_LIMIT = 20

const resultToTransitionSummary = (
  result: Machine.TransitionResult<typeof CheckoutState.Type, Message>,
): string =>
  Match.value(result).pipe(
    Match.tagsExhaustive({
      Transitioned: ({ from, messageTag, target }) =>
        `${from} -> ${target} on ${messageTag}`,
      Ignored: ({ messageTag, stateTag }) =>
        `${messageTag} ignored in ${stateTag}`,
    }),
  )

const stepMachine =
  (message: Message) =>
  (model: Model): UpdateReturn => {
    const result = checkoutMachine.step(model.checkout, message)

    const { state: nextCheckout } = result

    const transitionCommands = Match.value(result).pipe(
      Match.tagsExhaustive({
        Transitioned: ({ commands }) => commands,
        Ignored: () => [],
      }),
    )

    const transitionLogEntry: TransitionLogEntry = {
      id: model.nextTransitionLogId,
      summary: resultToTransitionSummary(result),
    }

    return {
      model: evo(model, {
        checkout: () => nextCheckout,
        transitionLog: flow(
          Array.prepend(transitionLogEntry),
          Array.take(TRANSITION_LOG_LIMIT),
        ),
        nextTransitionLogId: Number.increment,
      }),
      commands: transitionCommands,
    }
  }

const foldEditionRadioGroupOutMessage = RadioGroup.OutMessage.match<
  Update.Step<Model, Message>
>({
  Selected: ({ value }) =>
    stepMachine(
      Message.SelectedEdition({
        isShippingRequired: value === HARDCOVER_EDITION,
      }),
    ),
})

const foldEditionRadioGroup = Update.foldChild({
  update: EditionRadioGroup.update,
  read: (model: Model) => Option.some(model.editionRadioGroup),
  write: (model, nextEditionRadioGroup) =>
    evo(model, { editionRadioGroup: () => nextEditionRadioGroup }),
  toParentMessage: message => Message.GotEditionRadioGroupMessage({ message }),
  foldOutMessage: foldEditionRadioGroupOutMessage,
})

export const update = (model: Model, message: Message) =>
  Match.value(message).pipe(
    Match.withReturnType<UpdateReturn>(),
    Match.tag('GotEditionRadioGroupMessage', ({ message }) =>
      foldEditionRadioGroup(model, message),
    ),
    Match.tag(
      'ClickedContinue',
      'ClickedBack',
      'ClickedCancel',
      'ClickedPlaceOrder',
      'ClickedStartOver',
      'ToggledPaymentMethod',
      'SelectedEdition',
      'ToggledTermsAccepted',
      'UpdatedPromoCode',
      'SubmittedPromoCode',
      'SucceededPlaceOrder',
      () => stepMachine(message)(model),
    ),
    Match.exhaustive,
  )