Handle Form State with NanoStore in Nuxt 3

I stumbled upon Nanostores while playing around with alien signals.

Small. Performant. Can be used with any framework.

Example

Payment and shipping details

Create Nanostore

checkout.ts
import { map } from 'nanostores'

export interface CheckoutStore {
  acceptedTerms: boolean
  cc: string
  cvv: string
  email: string
  firstName: string
  lastName: string
  month: string
  year: string
}

export const initialCheckoutStore: CheckoutStore = {
  acceptedTerms: false,
  cc: '',
  cvv: '',
  email: '',
  firstName: '',
  lastName: '',
  month: '01',
  year: '25',
}

export type CheckoutErrorStore = { [Property in keyof CheckoutStore]?: { message: string } }

// Checkout Form Value Store
export const $checkout = map<CheckoutStore>(initialCheckoutStore)

// Checkout Form Error Store
export const $errors = map<CheckoutErrorStore>({})

export const requiredFields: { [key in keyof Partial<CheckoutStore>]: string } = {
  acceptedTerms: 'You must agree to the terms.',
  email: 'Email is required.',
  cc: 'Credit Card Number is required.',
  firstName: 'First Name is required.',
  lastName: 'Last Name is required.',
  cvv: 'CVV is required.',
}

useCheckout Composable

Used to handle all of the form reactivity, from state, errors, and validation.

useCheckout.ts
import type { FormSubmitEvent } from '@nuxt/ui'
import type { PreinitializedMapStore } from 'nanostores'
import { useStore } from '@nanostores/vue'
import {
  type CheckoutStore,
  type CheckoutErrorStore,
  $checkout,
  $errors,
  initialCheckoutStore,
  requiredFields,
} from '~/stores/checkout'

interface UseCheckout {
  $checkout: PreinitializedMapStore<CheckoutStore>
  $errors: PreinitializedMapStore<CheckoutErrorStore>
  errorStore: Readonly<Ref<Readonly<CheckoutErrorStore>>>
  months: Ref<string[]>
  store: Readonly<Ref<CheckoutStore>>
  years: Ref<string[]>
  onSubmit: (event: FormSubmitEvent<CheckoutStore>) => Promise<void>
  setFieldError: (field: keyof CheckoutErrorStore, message: string) => void
}

export function useCheckout(): UseCheckout {
  // Readonly
  const store = useStore($checkout)
  const errorStore = useStore($errors)
  // Ref necessary for USelect Component
  const months = shallowRef(MONTHS)
  const years = shallowRef(getYears())
  // For successful submission
  const toast = useToast()

  // Set Error Store
  const setErrors = (errs: CheckoutErrorStore) => {
    $errors.set(errs)
  }

  // Reset Checkout Form State
  const resetForm = () => {
    $checkout.set(initialCheckoutStore)
    $errors.set({})
  }

  // Set Error for a specific field
  const setFieldError = (field: keyof CheckoutErrorStore, message: string) => {
    const currentErrors = $errors.get()
    $errors.set({
      ...currentErrors,
      [field]: { message },
    })
  }

  // Validate All Fields
  const validate = (
    state: Partial<CheckoutStore>,
    customMessages?: Partial<Record<keyof CheckoutStore, string>>,
  ): CheckoutErrorStore => {
    const errors: CheckoutErrorStore = {}

    for (const field of Object.keys(requiredFields) as (keyof CheckoutStore)[]) {
      let error
      switch (field) {
        case 'firstName':
          error = validateFirstName(state.firstName || '')
          break
        case 'lastName':
          error = validateLastName(state.lastName || '')
          break
        case 'cc':
          error = validateCC(state.cc || '')
          break
        case 'email':
          error = validateEmail(state.email || '')
          break
        case 'cvv':
          error = validateCVV(state.cvv || '')
          break
        case 'acceptedTerms':
          error = validateAcceptedTerms(state.acceptedTerms || false)
          break
      }

      if (error) {
        errors[field] = { message: customMessages?.[field] || error.message }
      }
    }

    setErrors(errors)
    return errors
  }

  // Handle Form Submission
  async function onSubmit(event: FormSubmitEvent<CheckoutStore>) {
    event.preventDefault()
    const errors = validate($checkout.get())
    if (Object.keys(errors).length > 0) {
      return
    }

    toast.add({
      title: 'Success',
      description: 'Your checkout form has been submitted successfully.',
      color: 'success',
    })

    resetForm()
  }

  return {
    $checkout,
    $errors,
    errorStore,
    months,
    store,
    years,
    onSubmit,
    setFieldError,
  }
}
CheckoutForm.vue
<script setup lang="ts">
  const {
    $checkout, $errors, months, store, errorStore, years, onSubmit, setFieldError,
  } = useCheckout()
</script>

<template>
  <section
    aria-labelledby="payment-heading"
    class="flex-auto overflow-y-auto px-4 pt-12 pb-16 sm:px-6 sm:pt-16 lg:px-8 lg:pt-0 lg:pb-24"
  >
    <h2
      id="payment-heading"
      class="sr-only"
    >
      Payment and shipping details
    </h2>

    <div class="mx-auto max-w-lg lg:pt-16">
      <UForm
        id="checkoutForm"
        class="mt-6"
        :state="store"
        @submit="onSubmit"
      >
        <div class="grid grid-cols-12 gap-x-4 gap-y-6">
          <div class="col-span-full sm:col-span-6">
            <UFormField
              :error="errorStore.firstName?.message"
              label="First Name"
              name="cc-given-name"
              required
            >
              <UInput
                id="firstName"
                autocomplete="cc-given-name"
                class="w-full"
                :model-value="store.firstName"
                size="xl"
                type="text"
                @update:model-value="(val) => $checkout.setKey('firstName', val as string)"
              />
            </UFormField>
          </div>

          <div class="col-span-full sm:col-span-6">
            <UFormField
              :error="errorStore.lastName?.message"
              label="Last Name"
              name="cc-family-name"
              required
            >
              <UInput
                id="lastName"
                autocomplete="cc-family-name"
                class="w-full"
                :model-value="store.lastName"
                type="text"
                size="xl"
                @update:model-value="(val) => $checkout.setKey('lastName', val as string)"
              />
            </UFormField>
          </div>

          <div class="col-span-full">
            <UFormField
              :error="errorStore.cc?.message"
              label="Credit Card"
              name="cc-number"
              required
            >
              <UInput
                id="cc"
                autocomplete="cc-number"
                class="w-full"
                type="text"
                size="xl"
                :model-value="store.cc"
                @input="(e: Event) => {
                  const val = handleInputFormat({
                    target: e.target as HTMLInputElement,
                    formatter: formatCard,
                  });

                  $errors.setKey('cc', undefined)
                  $checkout.setKey('cc', val as string)
                }"
                @blur="(e: Event) => {
                  const err = validateCC((e.target as HTMLInputElement).value || '')
                  if (err) {
                    setFieldError('cc', err.message)
                  }
                }"
              />
            </UFormField>
          </div>

          <div class="col-span-4">
            <UFormField
              :error="errorStore.month?.message"
              label="Month"
              name="cc-exp-month"
              type="item"
              required
            >
              <USelect
                class="w-full"
                autocomplete="cc-exp-month"
                :default-value="store.month"
                :model-value="store.month"
                :items="months"
                size="xl"
                @update:model-value="(val) => $checkout.setKey('month', val)"
              />
            </UFormField>
          </div>

          <div class="col-span-4">
            <UFormField
              :error="errorStore.year?.message"
              label="Year"
              name="cc-exp-year"
              required
            >
              <USelect
                autocomplete="cc-exp-year"
                class="w-full"
                :default-value="store.year"
                :model-value="store.year"
                :items="years"
                size="xl"
                @update:model-value="(val) => $checkout.setKey('year', val)"
              />
            </UFormField>
          </div>

          <div class="col-span-4">
            <UFormField
              :error="errorStore.cvv?.message"
              label="CVV"
              name="cc-csc"
              required
            >
              <UInput
                id="cvv"
                autocomplete="cc-csc"
                class="w-full"
                :model-value="store.cvv"
                type="text"
                size="xl"
                @update:model-value="(val) => $checkout.setKey('cvv', val as string)"
              />
            </UFormField>
          </div>

          <div class="col-span-full">
            <UFormField
              :error="errorStore.email?.message"
              label="Email"
              name="email"
              required
            >
              <UInput
                id="email"
                class="w-full"
                type="text"
                :model-value="store.email"
                placeholder="you@example.com"
                size="xl"
                @update:model-value="(val) => $checkout.setKey('email', val as string)"
              />
            </UFormField>
          </div>
        </div>

        <div class="mt-6">
          <UFormField
            :error="errorStore.acceptedTerms?.message"
            name="terms"
            required
          >
            <UCheckbox
              id="terms"
              :default-value="store.acceptedTerms"
              :model-value="store.acceptedTerms"
              label="I agree to the terms"
              size="xl"
              @update:model-value="(val) => $checkout.setKey('acceptedTerms', val as boolean)"
            />
          </UFormField>
        </div>

        <UButton
          class="mt-6"
          type="submit"
          size="xl"
          title="Submit"
        >
          Submit
        </UButton>
      </UForm>
    </div>
  </section>
</template>