How to Build Reusable, Headless Form Components in Vue
Adrian Miu

Adrian Miu @adrian_miu

Joined:
May 24, 2025

How to Build Reusable, Headless Form Components in Vue

Publish Date: Jun 7
0 2

Some people believe the future of form development lies in headless components—powerful, logic-driven components that handle all the complexity of form state management while giving you complete control over the visual presentation.

If you've ever felt constrained by the styling limitations of traditional form libraries, or struggled to maintain consistent forms across different UI frameworks, headless components are the solution you've been looking for.

In this comprehensive guide, we'll explore how to build flexible, reusable form components using Enforma's headless architecture that work with any design system or UI framework.

Introduction to Headless Components

What Are Headless Components?

Headless components are UI-agnostic components that provide all the functionality and state management without any predefined styling or markup. Think of them as the "brain" of your forms—they handle validation, state management, user interactions, and accessibility, while leaving the visual presentation entirely up to you.

<!-- Traditional approach: Locked into specific styling -->
<FormInput 
  label="Email" 
  type="email" 
  class="cant-change-internal-structure"
/>

<!-- Headless approach: Complete control -->
<HeadlessField name="email">
  <template #default="{ value, error, events, attrs }">
    <!-- Your custom markup and styling -->
    <div class="my-custom-field-wrapper">
      <label class="my-label-style">Email</label>
      <input 
        class="my-input-style"
        :value="value"
        v-bind="attrs"
        v-on="events"
      />
      <span v-if="error" class="my-error-style">{{ error }}</span>
    </div>
  </template>
</HeadlessField>
Enter fullscreen mode Exit fullscreen mode

Benefits of Headless Forms

1. Complete UI Freedom
Build forms that perfectly match your design system without fighting against pre-built styles.

2. UI Framework Agnostic
Use the same form logic with PrimeVue, Vuetify, Quasar, or pure HTML—switch UI frameworks without rewriting form logic.

3. Reduced Bundle Size
No UI code included by default means smaller bundle sizes and faster load times.

4. Maximum Flexibility
Handle complex requirements like custom validation feedback, conditional fields, and unique user interactions.

5. Enhanced Maintainability
Separate form logic from presentation concerns, making both easier to test and maintain.

Enforma's Headless Architecture

Enforma provides three core headless components that form the foundation of any form system:

1. HeadlessForm - The Form Container

The main form component that manages overall form state, validation, and submission:

<HeadlessForm 
  :data="formData" 
  :rules="validationRules"
  :submit-handler="handleSubmit"
>
  <template #default="form">
    <!-- Access to complete form state -->
    <div>
      <span v-if="form.$isSubmitting.value">Submitting...</span>
      <!-- Your form fields here -->
    </div>
  </template>
</HeadlessForm>
Enter fullscreen mode Exit fullscreen mode

2. HeadlessField - Individual Field Management

Manages individual field state, validation, and user interactions:

<HeadlessField name="email">
  <template #default="field">
    <!-- Complete field control -->
    <input
      :id="field.id"
      :value="field.value"
      :aria-invalid="!!field.error"
      v-bind="field.attrs"
      v-on="field.events"
    />
    <div v-if="field.error">{{ field.error }}</div>
  </template>
</HeadlessField>
Enter fullscreen mode Exit fullscreen mode

3. HeadlessRepeatable - Dynamic Array Management

Handles arrays of fields with add, remove, and reorder functionality:

<HeadlessRepeatable name="skills">
  <template #default="{ value, add, remove, canAdd, canRemove }">
    <div v-for="(skill, index) in value" :key="index">
      <HeadlessField :name="`skills.${index}.name`">
        <!-- Field template -->
      </HeadlessField>
      <button @click="remove(index)" :disabled="!canRemove">
        Remove
      </button>
    </div>
    <button @click="add({ name: '' })" :disabled="!canAdd">
      Add Skill
    </button>
  </template>
</HeadlessRepeatable>
Enter fullscreen mode Exit fullscreen mode

Building Your First Headless Form

Let's start with a complete example that demonstrates the power of headless components:

<template>
  <div class="max-w-md mx-auto p-6">
    <h2 class="text-2xl font-bold mb-6">Contact Form</h2>

    <HeadlessForm
      :data="formData"
      :rules="validationRules"
      :submit-handler="handleSubmit"
    >
      <template #default="form">
        <form @submit.prevent="form.submit()" class="space-y-4">
          <!-- Name Field -->
          <HeadlessField name="name">
            <template #default="{ value, error, events, attrs, id }">
              <div>
                <label 
                  :for="id"
                  class="block text-sm font-medium text-gray-700 mb-1"
                >
                  Full Name
                </label>
                <input
                  :id="id"
                  :value="value"
                  v-bind="attrs"
                  v-on="events"
                  class="w-full p-2 border rounded-md"
                  :class="{ 'border-red-500': error, 'border-gray-300': !error }"
                />
                <div v-if="error" class="text-red-500 text-sm mt-1">
                  {{ error }}
                </div>
              </div>
            </template>
          </HeadlessField>

          <!-- Email Field -->
          <HeadlessField name="email">
            <template #default="{ value, error, events, attrs, id }">
              <div>
                <label 
                  :for="id"
                  class="block text-sm font-medium text-gray-700 mb-1"
                >
                  Email Address
                </label>
                <input
                  :id="id"
                  :value="value"
                  type="email"
                  v-bind="attrs"
                  v-on="events"
                  class="w-full p-2 border rounded-md"
                  :class="{ 'border-red-500': error, 'border-gray-300': !error }"
                />
                <div v-if="error" class="text-red-500 text-sm mt-1">
                  {{ error }}
                </div>
              </div>
            </template>
          </HeadlessField>

          <!-- Message Field -->
          <HeadlessField name="message">
            <template #default="{ value, error, events, attrs, id }">
              <div>
                <label 
                  :for="id"
                  class="block text-sm font-medium text-gray-700 mb-1"
                >
                  Message
                </label>
                <textarea
                  :id="id"
                  :value="value"
                  rows="4"
                  v-bind="attrs"
                  v-on="events"
                  class="w-full p-2 border rounded-md"
                  :class="{ 'border-red-500': error, 'border-gray-300': !error }"
                ></textarea>
                <div v-if="error" class="text-red-500 text-sm mt-1">
                  {{ error }}
                </div>
              </div>
            </template>
          </HeadlessField>

          <!-- Submit Button -->
          <button
            type="submit"
            :disabled="form.$isSubmitting.value"
            class="w-full bg-blue-600 text-white py-2 px-4 rounded-md disabled:bg-gray-400"
          >
            {{ form.$isSubmitting.value ? 'Sending...' : 'Send Message' }}
          </button>
        </form>
      </template>
    </HeadlessForm>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { HeadlessForm, HeadlessField } from '@encolajs/enforma'

const formData = {
  name: '',
  email: '',
  message: ''
}

const validationRules = {
  name: 'required',
  email: 'required|email',
  message: 'required|min:10'
}

const handleSubmit = async (data) => {
  // Simulate API call
  await new Promise(resolve => setTimeout(resolve, 1000))
  console.log('Form submitted:', data)
  alert('Message sent successfully!')
  return true
}
</script>
Enter fullscreen mode Exit fullscreen mode

Creating Reusable Components

Building a Base Field Component

The first step in creating reusable form components is building a base wrapper that reduces repetition:

<!-- components/AppFormField.vue -->
<template>
  <div class="form-field">
    <HeadlessField :name="name">
      <template #default="{ value, error, events, attrs, id }">
        <label 
          v-if="label"
          :for="id"
          class="field-label"
          :class="{ 'required': required }"
        >
          {{ label }}
          <span v-if="required" class="text-red-500">*</span>
        </label>

        <slot
          :value="value"
          :error="error"
          :events="events"
          :attrs="attrs"
          :id="id"
        />

        <div 
          v-if="error"
          :id="attrs['aria-errormessage']"
          class="field-error"
          role="alert"
        >
          {{ error }}
        </div>

        <div 
          v-if="help && !error"
          :id="attrs['aria-describedby']"
          class="field-help"
        >
          {{ help }}
        </div>
      </template>
    </HeadlessField>
  </div>
</template>

<script setup>
import { HeadlessField } from '@encolajs/enforma'

defineProps({
  name: {
    type: String,
    required: true
  },
  label: {
    type: String,
    default: null
  },
  help: {
    type: String,
    default: null
  },
  required: {
    type: Boolean,
    default: false
  }
})
</script>

<style scoped>
.form-field {
  margin-bottom: 1rem;
}

.field-label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 600;
  color: #374151;
}

.field-error {
  margin-top: 0.25rem;
  color: #dc2626;
  font-size: 0.875rem;
}

.field-help {
  margin-top: 0.25rem;
  color: #6b7280;
  font-size: 0.875rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Custom Input Components

Now build specific input types using your base component:

<!-- components/TextInput.vue -->
<template>
  <AppFormField 
    :name="name" 
    :label="label" 
    :help="help"
    :required="required"
  >
    <template #default="{ value, error, events, attrs, id }">
      <input
        :id="id"
        :type="type"
        :value="value"
        :placeholder="placeholder"
        v-bind="attrs"
        v-on="events"
        class="form-input"
        :class="{ 'input-error': error }"
      />
    </template>
  </AppFormField>
</template>

<script setup>
import AppFormField from './AppFormField.vue'

defineProps({
  name: { type: String, required: true },
  label: { type: String, default: null },
  type: { type: String, default: 'text' },
  placeholder: { type: String, default: null },
  help: { type: String, default: null },
  required: { type: Boolean, default: false }
})
</script>

<style scoped>
.form-input {
  width: 100%;
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 0.375rem;
  font-size: 1rem;
  transition: border-color 0.15s ease-in-out;
}

.form-input:focus {
  outline: none;
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.input-error {
  border-color: #dc2626;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Select Component

<!-- components/SelectInput.vue -->
<template>
  <AppFormField 
    :name="name" 
    :label="label" 
    :help="help"
    :required="required"
  >
    <template #default="{ value, error, events, attrs, id }">
      <select
        :id="id"
        :value="value"
        v-bind="attrs"
        v-on="events"
        class="form-select"
        :class="{ 'input-error': error }"
      >
        <option value="" v-if="placeholder">{{ placeholder }}</option>
        <option 
          v-for="option in options" 
          :key="option.value"
          :value="option.value"
          :disabled="option.disabled"
        >
          {{ option.label }}
        </option>
      </select>
    </template>
  </AppFormField>
</template>

<script setup>
import AppFormField from './AppFormField.vue'

defineProps({
  name: { type: String, required: true },
  label: { type: String, default: null },
  options: { type: Array, required: true },
  placeholder: { type: String, default: 'Select an option' },
  help: { type: String, default: null },
  required: { type: Boolean, default: false }
})
</script>

<style scoped>
.form-select {
  width: 100%;
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 0.375rem;
  font-size: 1rem;
  background-color: white;
  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
  background-position: right 0.5rem center;
  background-repeat: no-repeat;
  background-size: 1.5em 1.5em;
  padding-right: 2.5rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Advanced Component Examples

Date Range Component

Here's a more complex component that handles two related date inputs:

<!-- components/DateRangeField.vue -->
<template>
  <div class="date-range-field">
    <label v-if="label" class="field-label">{{ label }}</label>

    <div class="date-inputs">
      <HeadlessField :name="`${name}.start`">
        <template #default="startField">
          <div class="date-input-wrapper">
            <label :for="startField.id" class="sr-only">Start Date</label>
            <input
              :id="startField.id"
              type="date"
              :value="startField.value"
              v-bind="startField.attrs"
              v-on="startField.events"
              :max="endValue"
              class="date-input"
              :class="{ 'input-error': startField.error }"
            />
            <span class="date-label">From</span>
          </div>
        </template>
      </HeadlessField>

      <div class="date-separator"></div>

      <HeadlessField :name="`${name}.end`">
        <template #default="endField">
          <div class="date-input-wrapper">
            <label :for="endField.id" class="sr-only">End Date</label>
            <input
              :id="endField.id"
              type="date"
              :value="endField.value"
              v-bind="endField.attrs"
              v-on="endField.events"
              :min="startValue"
              class="date-input"
              :class="{ 'input-error': endField.error }"
            />
            <span class="date-label">To</span>
          </div>
        </template>
      </HeadlessField>
    </div>

    <div v-if="hasErrors" class="field-error">
      {{ firstError }}
    </div>
  </div>
</template>

<script setup>
import { computed, inject } from 'vue'
import { HeadlessField } from '@encolajs/enforma'

const props = defineProps({
  name: { type: String, required: true },
  label: { type: String, required: true }
})

// Access form context to get values for min/max constraints
const form = inject('form')

const startValue = computed(() => form.getFieldValue(`${props.name}.start`))
const endValue = computed(() => form.getFieldValue(`${props.name}.end`))

const startField = computed(() => form.getField(`${props.name}.start`))
const endField = computed(() => form.getField(`${props.name}.end`))

const hasErrors = computed(() => 
  startField.value?.error || endField.value?.error
)

const firstError = computed(() => 
  startField.value?.error || endField.value?.error
)
</script>

<style scoped>
.date-range-field {
  margin-bottom: 1rem;
}

.date-inputs {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.date-input-wrapper {
  flex: 1;
  position: relative;
}

.date-input {
  width: 100%;
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 0.375rem;
  font-size: 1rem;
}

.date-label {
  position: absolute;
  top: -0.5rem;
  left: 0.75rem;
  background: white;
  padding: 0 0.25rem;
  font-size: 0.75rem;
  color: #6b7280;
  font-weight: 500;
}

.date-separator {
  color: #6b7280;
  font-weight: 500;
  margin: 0 0.5rem;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Working with Dynamic Arrays

Skills Repeatable Component

<!-- components/SkillsField.vue -->
<template>
  <div class="skills-field">
    <h3 class="field-label">{{ label }}</h3>

    <HeadlessRepeatable 
      :name="name" 
      :min="minItems" 
      :max="maxItems"
    >
      <template #default="{ value, add, remove, canAdd, canRemove, moveUp, moveDown }">
        <div class="skills-list">
          <div 
            v-for="(skill, index) in value" 
            :key="index"
            class="skill-item"
          >
            <div class="skill-fields">
              <TextInput
                :name="`${name}.${index}.name`"
                label="Skill Name"
                placeholder="e.g., JavaScript, Design, Project Management"
                class="skill-name"
              />

              <SelectInput
                :name="`${name}.${index}.level`"
                label="Proficiency"
                :options="levelOptions"
                class="skill-level"
              />

              <TextInput
                :name="`${name}.${index}.years`"
                label="Years"
                type="number"
                placeholder="0"
                class="skill-years"
              />
            </div>

            <div class="skill-actions">
              <button
                type="button"
                @click="moveUp(index)"
                :disabled="index === 0"
                class="move-button"
                title="Move up"
              ></button>

              <button
                type="button"
                @click="moveDown(index)"
                :disabled="index === value.length - 1"
                class="move-button"
                title="Move down"
              ></button>

              <button
                type="button"
                @click="remove(index)"
                :disabled="!canRemove"
                class="remove-button"
                title="Remove skill"
              >
                ×
              </button>
            </div>
          </div>
        </div>

        <button
          type="button"
          @click="add({ name: '', level: '', years: 0 })"
          :disabled="!canAdd"
          class="add-button"
        >
          + Add Skill
        </button>

        <div v-if="!canAdd" class="max-items-notice">
          Maximum {{ maxItems }} skills allowed
        </div>
      </template>
    </HeadlessRepeatable>
  </div>
</template>

<script setup>
import { HeadlessRepeatable } from '@encolajs/enforma'
import TextInput from './TextInput.vue'
import SelectInput from './SelectInput.vue'

defineProps({
  name: { type: String, required: true },
  label: { type: String, default: 'Skills' },
  minItems: { type: Number, default: 0 },
  maxItems: { type: Number, default: 10 }
})

const levelOptions = [
  { value: 'beginner', label: 'Beginner' },
  { value: 'intermediate', label: 'Intermediate' },
  { value: 'advanced', label: 'Advanced' },
  { value: 'expert', label: 'Expert' }
]
</script>

<style scoped>
.skills-field {
  margin-bottom: 2rem;
}

.skills-list {
  space-y: 1rem;
}

.skill-item {
  display: flex;
  gap: 1rem;
  padding: 1rem;
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  background-color: #f9fafb;
}

.skill-fields {
  flex: 1;
  display: grid;
  grid-template-columns: 2fr 1fr 1fr;
  gap: 1rem;
  align-items: end;
}

.skill-actions {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.move-button,
.remove-button {
  width: 2rem;
  height: 2rem;
  border: 1px solid #d1d5db;
  border-radius: 0.25rem;
  background: white;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  cursor: pointer;
}

.move-button:hover,
.remove-button:hover {
  background-color: #f3f4f6;
}

.remove-button {
  color: #dc2626;
  border-color: #fca5a5;
}

.remove-button:hover {
  background-color: #fef2f2;
}

.add-button {
  padding: 0.75rem 1rem;
  background-color: #3b82f6;
  color: white;
  border: none;
  border-radius: 0.375rem;
  font-weight: 500;
  cursor: pointer;
}

.add-button:hover:not(:disabled) {
  background-color: #2563eb;
}

.add-button:disabled {
  background-color: #9ca3af;
  cursor: not-allowed;
}

.max-items-notice {
  color: #6b7280;
  font-size: 0.875rem;
  font-style: italic;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Accessibility and Best Practices

Automatic ARIA Support

Enforma's headless components automatically provide comprehensive accessibility features:

<template>
  <HeadlessField name="email">
    <template #default="{ attrs, events, id, error }">
      <!-- All ARIA attributes are automatically provided -->
      <input
        :id="id"
        v-bind="attrs"  <!-- Includes aria-invalid, aria-required, etc. -->
        v-on="events"
        type="email"
      />

      <!-- Error message properly linked via aria-errormessage -->
      <div 
        v-if="error"
        :id="attrs['aria-errormessage']"
        role="alert"
      >
        {{ error }}
      </div>
    </template>
  </HeadlessField>
</template>
Enter fullscreen mode Exit fullscreen mode

Enhanced Accessibility Example

<template>
  <HeadlessField name="password">
    <template #default="{ value, error, events, attrs, id }">
      <div class="password-field">
        <label :for="id" class="field-label">
          Password
          <span class="required">*</span>
        </label>

        <div class="password-input-wrapper">
          <input
            :id="id"
            :type="showPassword ? 'text' : 'password'"
            :value="value"
            v-bind="attrs"
            v-on="events"
            class="password-input"
            :aria-describedby="`${id}-requirements`"
          />

          <button
            type="button"
            @click="showPassword = !showPassword"
            :aria-label="showPassword ? 'Hide password' : 'Show password'"
            class="password-toggle"
          >
            {{ showPassword ? '👁️' : '👁️‍🗨️' }}
          </button>
        </div>

        <!-- Password requirements -->
        <div 
          :id="`${id}-requirements`"
          class="password-requirements"
          :aria-live="value ? 'polite' : 'off'"
        >
          <div 
            class="requirement"
            :class="{ 'met': value.length >= 8 }"
          >
            ✓ At least 8 characters
          </div>
          <div 
            class="requirement"
            :class="{ 'met': /[A-Z]/.test(value) }"
          >
            ✓ One uppercase letter
          </div>
          <div 
            class="requirement"
            :class="{ 'met': /[0-9]/.test(value) }"
          >
            ✓ One number
          </div>
        </div>

        <div v-if="error" class="field-error" role="alert">
          {{ error }}
        </div>
      </div>
    </template>
  </HeadlessField>
</template>

<script setup>
import { ref } from 'vue'

const showPassword = ref(false)
</script>
Enter fullscreen mode Exit fullscreen mode

Complete Example: Registration Form

Here's a comprehensive example that brings together all the concepts we've covered:

<template>
  <div class="max-w-2xl mx-auto p-6">
    <h1 class="text-3xl font-bold mb-8 text-center">Create Your Account</h1>

    <HeadlessForm
      :data="formData"
      :rules="validationRules"
      :custom-messages="customMessages"
      :submit-handler="handleSubmit"
    >
      <template #default="form">
        <div class="space-y-8">
          <!-- Personal Information -->
          <section>
            <h2 class="text-xl font-semibold mb-4">Personal Information</h2>
            <div class="grid grid-cols-2 gap-4">
              <TextInput
                name="personal.firstName"
                label="First Name"
                required
              />
              <TextInput
                name="personal.lastName"
                label="Last Name"
                required
              />
            </div>
            <TextInput
              name="personal.email"
              label="Email Address"
              type="email"
              required
            />
            <TextInput
              name="personal.password"
              label="Password"
              type="password"
              required
            />
          </section>

          <!-- Professional Information -->
          <section>
            <h2 class="text-xl font-semibold mb-4">Professional Background</h2>
            <TextInput
              name="professional.jobTitle"
              label="Current Job Title"
              placeholder="e.g., Frontend Developer"
            />
            <SelectInput
              name="professional.experience"
              label="Years of Experience"
              :options="experienceOptions"
              required
            />
            <SkillsField
              name="professional.skills"
              label="Technical Skills"
              :min-items="1"
              :max-items="10"
            />
          </section>

          <!-- Contact Preferences -->
          <section>
            <h2 class="text-xl font-semibold mb-4">Contact Preferences</h2>
            <HeadlessField name="preferences.newsletter">
              <template #default="{ value, events, id }">
                <div class="flex items-center">
                  <input
                    :id="id"
                    type="checkbox"
                    :checked="value"
                    @change="events.input($event.target.checked)"
                    class="rounded border-gray-300"
                  />
                  <label :for="id" class="ml-2">
                    Subscribe to our newsletter
                  </label>
                </div>
              </template>
            </HeadlessField>
          </section>

          <!-- Form Actions -->
          <div class="flex gap-4 pt-4">
            <button
              type="button"
              @click="form.reset()"
              class="flex-1 py-2 px-4 border border-gray-300 rounded-md hover:bg-gray-50"
            >
              Reset Form
            </button>
            <button
              type="submit"
              :disabled="form.$isSubmitting.value"
              class="flex-1 py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
            >
              {{ form.$isSubmitting.value ? 'Creating Account...' : 'Create Account' }}
            </button>
          </div>

          <!-- Form State Display (for demo) -->
          <div v-if="showDebug" class="mt-8 p-4 bg-gray-100 rounded-md">
            <h3 class="font-semibold mb-2">Form State (Debug)</h3>
            <div class="text-sm space-y-1">
              <div>Is Dirty: {{ form.$isDirty.value }}</div>
              <div>Is Valid: {{ form.$isValid.value }}</div>
              <div>Is Submitting: {{ form.$isSubmitting.value }}</div>
            </div>
          </div>
        </div>
      </template>
    </HeadlessForm>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { HeadlessForm, HeadlessField } from '@encolajs/enforma'
import TextInput from './components/TextInput.vue'
import SelectInput from './components/SelectInput.vue'
import SkillsField from './components/SkillsField.vue'

const showDebug = ref(false)

const formData = {
  personal: {
    firstName: '',
    lastName: '',
    email: '',
    password: ''
  },
  professional: {
    jobTitle: '',
    experience: '',
    skills: [{ name: '', level: '', years: 0 }]
  },
  preferences: {
    newsletter: false
  }
}

const validationRules = {
  'personal.firstName': 'required',
  'personal.lastName': 'required',
  'personal.email': 'required|email',
  'personal.password': 'required|min:8',
  'professional.experience': 'required',
  'professional.skills.*.name': 'required',
  'professional.skills.*.level': 'required'
}

const customMessages = {
  'personal.password.min': 'Password must be at least 8 characters long',
  'professional.skills.*.name.required': 'Skill name is required',
  'professional.skills.*.level.required': 'Please select a proficiency level'
}

const experienceOptions = [
  { value: '0-1', label: '0-1 years' },
  { value: '2-3', label: '2-3 years' },
  { value: '4-6', label: '4-6 years' },
  { value: '7-10', label: '7-10 years' },
  { value: '10+', label: '10+ years' }
]

const handleSubmit = async (data) => {
  console.log('Submitting registration:', data)

  // Simulate API call
  await new Promise(resolve => setTimeout(resolve, 2000))

  alert('Account created successfully!')
  return true
}
</script>
Enter fullscreen mode Exit fullscreen mode

Final Thoughts: Reusability and Maintainability

Benefits You've Gained

By using Enforma's headless approach, you've achieved:

  1. Complete UI Control: Build forms that perfectly match your design system
  2. Framework Independence: Switch UI libraries without rewriting form logic
  3. Enhanced Accessibility: Automatic ARIA support with full customization
  4. Maximum Reusability: Components work across projects and teams
  5. Better Testing: Separate logic from presentation for easier unit testing
  6. Improved Performance: Only include the UI code you actually need

Best Practices Summary

  • Start with base components that handle common patterns
  • Use composition to build complex fields from simple parts
  • Leverage Enforma's automatic accessibility features
  • Test form logic separately from UI presentation
  • Create a component library for your organization
  • Document your custom components with clear examples

The headless approach transforms form development from a tedious, repetitive task into a flexible, creative process. Whether you're building simple contact forms or complex multi-step wizards, Enforma's headless components provide the foundation for beautiful, accessible, and maintainable forms.

Ready to get started? Install Enforma and begin building your first headless form component today.

Try Enforma Now →

Comments 2 total

Add comment