Mastering Nested Forms in Enforma VueJS
Adrian Miu

Adrian Miu @adrian_miu

Joined:
May 24, 2025

Mastering Nested Forms in Enforma VueJS

Publish Date: Jun 16
0 0

Building forms with nested data structures is one of the challenging aspects of modern web development. Whether you're creating an e-commerce checkout flow with shipping and billing addresses, a user profile with nested preferences, or a job application with multiple experience entries, nested forms can quickly become a tangled mess of reactive watchers, complex v-model bindings, and fragile state management.

If you've ever found yourself wrestling with deeply nested form data, struggling with array validation, or writing repetitive code for add/remove/reorder functionality, you understand why nested forms matter—and why they're so difficult to get right.

Why Nested Forms Matter in Real-World Applications

E-commerce Checkout: The Classic Example

Consider a typical e-commerce checkout form that needs to handle:

// The data structure every e-commerce site needs
const checkout = {
  customer: {
    firstName: "John",
    lastName: "Doe",
    email: "john@example.com"
  },
  shippingAddress: {
    street: "123 Main St",
    city: "Springfield",
    state: "IL",
    zipCode: "62701"
  },
  billingAddress: {
    // Same structure, or "same as shipping"
  },
  items: [
    {
      productId: "abc-123",
      quantity: 2,
      price: 29.99,
      customizations: {
        size: "L",
        color: "blue",
        engraving: "Happy Birthday!"
      }
    }
  ],
  paymentMethods: [
    {
      type: "credit_card",
      cardNumber: "****-****-****-1234",
      primary: true
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Job Applications and User Profiles

Modern applications often require complex nested structures:

// Professional profile with dynamic arrays
const resume = {
  personal: {
    firstName: "Sarah",
    lastName: "Chen",
    email: "sarah@example.com"
  },
  experience: [
    {
      company: "Tech Corp",
      position: "Senior Developer",
      startDate: "2020-01-15",
      endDate: "2023-08-30",
      responsibilities: [
        "Led development of user dashboard",
        "Mentored junior developers"
      ],
      technologies: ["Vue", "Node.js", "PostgreSQL"]
    }
  ],
  education: [
    {
      institution: "State University",
      degree: "Computer Science",
      graduationYear: 2019,
      courses: ["Data Structures", "Web Development"]
    }
  ],
  skills: [
    { name: "JavaScript", level: "Expert", years: 8 },
    { name: "Vue.js", level: "Advanced", years: 5 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The Traditional Challenges with v-model and Deep Nested Data

Complex v-model Bindings

The traditional Vue approach becomes unwieldy quickly:

<template>
  <!-- The nightmare of nested v-model bindings -->
  <input v-model="form.customer.firstName" />
  <input v-model="form.customer.lastName" />

  <!-- Arrays are even worse -->
  <div v-for="(item, index) in form.items" :key="index">
    <input v-model="form.items[index].quantity" />
    <input v-model="form.items[index].customizations.size" />
    <select v-model="form.items[index].customizations.color">
      <!-- options -->
    </select>
  </div>

  <!-- Dynamic experiences -->
  <div v-for="(exp, index) in form.experience" :key="index">
    <input v-model="form.experience[index].company" />
    <input v-model="form.experience[index].position" />

    <!-- Nested arrays within arrays -->
    <div v-for="(tech, techIndex) in exp.technologies" :key="techIndex">
      <input v-model="form.experience[index].technologies[techIndex]" />
    </div>
  </div>
</template>

<script>
// 200+ lines of reactive state management...
const form = reactive({
  customer: { firstName: '', lastName: '', email: '' },
  items: [],
  experience: []
})

// Complex watchers for validation
watch(() => form.customer.email, (newEmail) => {
  // Email validation logic
})

// Manual array management
const addExperience = () => {
  form.experience.push({
    company: '',
    position: '',
    technologies: []
  })
}

const removeExperience = (index) => {
  form.experience.splice(index, 1)
}
</script>
Enter fullscreen mode Exit fullscreen mode

Validation Nightmare

Cross-field validation and nested validation rules become incredibly complex:

// The validation spaghetti you're forced to write
const validateForm = () => {
  // Clear previous errors
  errors.value = {}

  // Validate customer info
  if (!form.customer.firstName) {
    errors.value['customer.firstName'] = 'First name is required'
  }

  // Validate each item
  form.items.forEach((item, index) => {
    if (!item.quantity || item.quantity < 1) {
      errors.value[`items.${index}.quantity`] = 'Quantity must be at least 1'
    }
    if (item.customizations.size && !item.customizations.color) {
      errors.value[`items.${index}.customizations.color`] = 'Color required when size is specified'
    }
  })

  // Validate experience dates
  form.experience.forEach((exp, index) => {
    if (exp.endDate && exp.startDate && exp.endDate < exp.startDate) {
      errors.value[`experience.${index}.endDate`] = 'End date must be after start date'
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Enforma's Elegant Approach to Nested Forms

Enforma transforms this complexity into clean, maintainable code through powerful abstraction and intelligent handling of nested structures.

Simple Dot Notation for Deep Paths

Instead of complex v-model bindings, Enforma uses intuitive dot notation:

<template>
  <Enforma :data="formData" :rules="validationRules">
    <!-- Clean, readable field definitions -->
    <EnformaField name="customer.firstName" label="First Name" />
    <EnformaField name="customer.lastName" label="Last Name" />
    <EnformaField name="customer.email" label="Email" />

    <!-- Nested addresses -->
    <EnformaSection title="Shipping Address">
      <EnformaField name="shippingAddress.street" label="Street" />
      <EnformaField name="shippingAddress.city" label="City" />
      <EnformaField name="shippingAddress.state" label="State" />
    </EnformaSection>
  </Enforma>
</template>

<script setup>
// Clean, declarative data structure
const formData = {
  customer: {
    firstName: '',
    lastName: '',
    email: ''
  },
  shippingAddress: {
    street: '',
    city: '',
    state: ''
  }
}

// Simple validation rules using the same dot notation
const validationRules = {
  'customer.firstName': 'required',
  'customer.lastName': 'required', 
  'customer.email': 'required|email',
  'shippingAddress.street': 'required',
  'shippingAddress.city': 'required'
}
</script>
Enter fullscreen mode Exit fullscreen mode

Powerful Array Management with EnformaRepeatable

For dynamic arrays, Enforma provides the EnformaRepeatable component:

<template>
  <Enforma :data="formData" :rules="validationRules">
    <!-- Professional Experience Array -->
    <EnformaRepeatable name="experience" add-label="Add Experience">
      <template #default="{ value, add, remove, canRemove, index }">
        <div v-for="(exp, i) in value" :key="i" class="experience-item">
          <h4>Experience {{ i + 1 }}</h4>

          <div class="grid grid-cols-2 gap-4">
            <EnformaField 
              :name="`experience.${i}.company`" 
              label="Company" 
            />
            <EnformaField 
              :name="`experience.${i}.position`" 
              label="Position" 
            />
          </div>

          <div class="grid grid-cols-2 gap-4">
            <EnformaField 
              :name="`experience.${i}.startDate`" 
              label="Start Date"
              input="date"
            />
            <EnformaField 
              :name="`experience.${i}.endDate`" 
              label="End Date"
              input="date"
            />
          </div>

          <!-- Nested skills array within experience -->
          <EnformaRepeatable :name="`experience.${i}.technologies`">
            <template #default="{ value: techs, add: addTech, remove: removeTech }">
              <div class="technologies">
                <label>Technologies:</label>
                <div v-for="(tech, techIndex) in techs" :key="techIndex" class="tech-item">
                  <EnformaField 
                    :name="`experience.${i}.technologies.${techIndex}`" 
                    placeholder="Technology name"
                  />
                  <button @click="removeTech(techIndex)">Remove</button>
                </div>
                <button @click="addTech('')">Add Technology</button>
              </div>
            </template>
          </EnformaRepeatable>

          <button @click="remove(i)" :disabled="!canRemove">
            Remove Experience
          </button>
        </div>

        <button @click="add({ company: '', position: '', technologies: [] })">
          Add Experience
        </button>
      </template>
    </EnformaRepeatable>
  </Enforma>
</template>

<script setup>
const formData = {
  experience: [
    {
      company: '',
      position: '',
      startDate: '',
      endDate: '',
      technologies: []
    }
  ]
}

// Array validation using wildcard notation
const validationRules = {
  'experience.*.company': 'required',
  'experience.*.position': 'required',
  'experience.*.startDate': 'required|date',
  'experience.*.endDate': 'date|date_after:@experience.*.startDate',
  'experience.*.technologies.*': 'required'
}
</script>
Enter fullscreen mode Exit fullscreen mode

Advanced Nested Form Patterns

Conditional Nested Sections

Handle complex conditional logic within nested structures:

<template>
  <Enforma :data="formData" :rules="validationRules">
    <!-- Address Information -->
    <EnformaField name="shippingAddress.street" label="Shipping Street" />
    <EnformaField name="shippingAddress.city" label="Shipping City" />

    <!-- Conditional billing address -->
    <EnformaField 
      name="sameAsBilling" 
      input="checkbox" 
      label="Billing address same as shipping"
    />

    <!-- Only show billing fields if different from shipping -->
    <div v-if="!formData.sameAsBilling">
      <EnformaField name="billingAddress.street" label="Billing Street" />
      <EnformaField name="billingAddress.city" label="Billing City" />
    </div>

    <!-- Order items with dynamic customizations -->
    <EnformaRepeatable name="items">
      <template #default="{ value, add, remove }">
        <div v-for="(item, index) in value" :key="index">
          <EnformaField 
            :name="`items.${index}.productName`" 
            label="Product"
          />

          <!-- Show customization fields based on product type -->
          <div v-if="item.productName === 'T-Shirt'">
            <EnformaField 
              :name="`items.${index}.customizations.size`"
              label="Size"
              input="select"
              :options="['S', 'M', 'L', 'XL']"
            />
            <EnformaField 
              :name="`items.${index}.customizations.color`"
              label="Color"
              input="select"
              :options="['Red', 'Blue', 'Green']"
            />
          </div>

          <div v-if="item.productName === 'Mug'">
            <EnformaField 
              :name="`items.${index}.customizations.engraving`"
              label="Custom Engraving"
              input="textarea"
            />
          </div>
        </div>
      </template>
    </EnformaRepeatable>
  </Enforma>
</template>
Enter fullscreen mode Exit fullscreen mode

Cross-Field Validation in Nested Data

Enforma's validation system excels at handling complex relationships:

const validationRules = {
  // Basic field validation
  'shippingAddress.street': 'required',
  'shippingAddress.city': 'required',

  // Conditional validation - billing required if different from shipping
  'billingAddress.street': 'required_unless:sameAsBilling,true',
  'billingAddress.city': 'required_unless:sameAsBilling,true',

  // Array validation with cross-references
  'items.*.quantity': 'required|integer|min:1',
  'items.*.customizations.size': 'required_if:items.*.productName,T-Shirt',
  'items.*.customizations.color': 'required_if:items.*.productName,T-Shirt',
  'items.*.customizations.engraving': 'required_if:items.*.productName,Mug',

  // Date range validation across experience entries
  'experience.*.startDate': 'required|date',
  'experience.*.endDate': 'date|date_after:@experience.*.startDate',

  // Complex business rules
  'experience.*.salary': 'numeric|gte:@experience.*.minSalary'
}
Enter fullscreen mode Exit fullscreen mode

Handling Array Operations: Add, Remove, Reorder

Smart Array Management

Enforma handles all the complex array operations automatically:

<template>
  <EnformaRepeatable 
    name="teamMembers" 
    :min="1" 
    :max="10"
    :allow-sort="true"
  >
    <template #default="{ value, add, remove, moveUp, moveDown, canAdd, canRemove }">
      <div v-for="(member, index) in value" :key="index" class="team-member">
        <div class="member-fields">
          <EnformaField 
            :name="`teamMembers.${index}.name`" 
            label="Name"
          />
          <EnformaField 
            :name="`teamMembers.${index}.role`" 
            label="Role"
          />
          <EnformaField 
            :name="`teamMembers.${index}.email`" 
            label="Email"
          />
        </div>

        <div class="member-actions">
          <!-- Reorder buttons -->
          <button 
            @click="moveUp(index)" 
            :disabled="index === 0"
            title="Move up"
          ></button>
          <button 
            @click="moveDown(index)" 
            :disabled="index === value.length - 1"
            title="Move down"
          ></button>

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

      <!-- Add button -->
      <button 
        @click="add({ name: '', role: '', email: '' })" 
        :disabled="!canAdd"
      >
        Add Team Member
      </button>

      <!-- Status feedback -->
      <div class="array-status">
        {{ value.length }} of {{ max }} members
        <span v-if="!canAdd">(Maximum reached)</span>
      </div>
    </template>
  </EnformaRepeatable>
</template>
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Complete E-commerce Checkout

Here's a comprehensive example showing all the concepts together:

<template>
  <div class="checkout-form">
    <h1>Checkout</h1>

    <EnformaSchema
      :schema="checkoutSchema"
      :data="formData"
      :rules="validationRules"
      :submit-handler="processOrder"
    >
      <!-- Custom slot for special handling -->
      <template #field(paymentMethods)>
        <div class="payment-methods">
          <h3>Payment Methods</h3>
          <EnformaRepeatable name="paymentMethods" :min="1">
            <template #default="{ value, add, remove }">
              <div v-for="(method, index) in value" :key="index" class="payment-method">
                <EnformaField 
                  :name="`paymentMethods.${index}.type`"
                  label="Payment Type"
                  input="select"
                  :options="['credit_card', 'debit_card', 'paypal']"
                />

                <!-- Credit card specific fields -->
                <div v-if="method.type === 'credit_card'">
                  <EnformaField 
                    :name="`paymentMethods.${index}.cardNumber`"
                    label="Card Number"
                    input="text"
                    placeholder="1234 5678 9012 3456"
                  />
                  <div class="card-row">
                    <EnformaField 
                      :name="`paymentMethods.${index}.expiryDate`"
                      label="Expiry"
                      placeholder="MM/YY"
                    />
                    <EnformaField 
                      :name="`paymentMethods.${index}.cvv`"
                      label="CVV"
                      input="password"
                      placeholder="123"
                    />
                  </div>
                </div>

                <!-- PayPal specific fields -->
                <div v-if="method.type === 'paypal'">
                  <EnformaField 
                    :name="`paymentMethods.${index}.paypalEmail`"
                    label="PayPal Email"
                    input="email"
                  />
                </div>

                <EnformaField 
                  :name="`paymentMethods.${index}.primary`"
                  input="checkbox"
                  label="Primary payment method"
                />
              </div>
            </template>
          </EnformaRepeatable>
        </div>
      </template>
    </EnformaSchema>
  </div>
</template>

<script setup>
const checkoutSchema = {
  // Customer Information
  customer_section: {
    type: 'section',
    title: 'Customer Information'
  },
  'customer.firstName': {
    type: 'field',
    section: 'customer_section',
    label: 'First Name',
    required: true
  },
  'customer.lastName': {
    type: 'field',
    section: 'customer_section', 
    label: 'Last Name',
    required: true
  },
  'customer.email': {
    type: 'field',
    section: 'customer_section',
    label: 'Email',
    required: true
  },

  // Shipping Address
  shipping_section: {
    type: 'section',
    title: 'Shipping Address'
  },
  'shippingAddress.street': {
    type: 'field',
    section: 'shipping_section',
    label: 'Street Address',
    required: true
  },
  'shippingAddress.city': {
    type: 'field',
    section: 'shipping_section',
    label: 'City',
    required: true
  },

  // Same as shipping checkbox
  sameAsBilling: {
    type: 'field',
    section: 'shipping_section',
    type: 'checkbox',
    label: 'Billing address same as shipping'
  },

  // Order Items
  items_section: {
    type: 'section',
    title: 'Order Items'
  },
  items: {
    type: 'repeatable_table',
    section: 'items_section',
    subfields: {
      productName: {
        label: 'Product',
        required: true
      },
      quantity: {
        label: 'Quantity',
        type: 'number',
        min: 1,
        required: true
      },
      price: {
        label: 'Unit Price',
        type: 'number',
        readonly: true
      }
    }
  }
}

const formData = {
  customer: {
    firstName: '',
    lastName: '',
    email: ''
  },
  shippingAddress: {
    street: '',
    city: '',
    state: '',
    zipCode: ''
  },
  billingAddress: {
    street: '',
    city: '',
    state: '',
    zipCode: ''
  },
  sameAsBilling: true,
  items: [
    { productName: '', quantity: 1, price: 0 }
  ],
  paymentMethods: [
    { type: 'credit_card', primary: true }
  ]
}

const validationRules = {
  'customer.firstName': 'required',
  'customer.lastName': 'required',
  'customer.email': 'required|email',
  'shippingAddress.street': 'required',
  'shippingAddress.city': 'required',
  'billingAddress.street': 'required_unless:sameAsBilling,true',
  'billingAddress.city': 'required_unless:sameAsBilling,true',
  'items.*.productName': 'required',
  'items.*.quantity': 'required|integer|min:1',
  'paymentMethods.*.type': 'required',
  'paymentMethods.*.cardNumber': 'required_if:paymentMethods.*.type,credit_card|credit_card',
  'paymentMethods.*.expiryDate': 'required_if:paymentMethods.*.type,credit_card',
  'paymentMethods.*.cvv': 'required_if:paymentMethods.*.type,credit_card|digits:3',
  'paymentMethods.*.paypalEmail': 'required_if:paymentMethods.*.type,paypal|email'
}

const processOrder = async (data) => {
  console.log('Processing order:', data)
  // Handle order submission
  return true
}
</script>
Enter fullscreen mode Exit fullscreen mode

Best Practices for Nested Forms

1. Structure Your Data Thoughtfully

Plan your nested data structure to match your business logic:

// Good: Logical grouping
{
  customer: { /* customer info */ },
  shipping: { /* shipping details */ },
  billing: { /* billing details */ },
  items: [ /* order items */ ]
}

// Avoid: Flat structure that should be nested
{
  customerFirstName: '',
  customerLastName: '',
  shippingStreet: '',
  shippingCity: '',
  item1Name: '',
  item1Quantity: 0
  // This becomes unmanageable quickly
}
Enter fullscreen mode Exit fullscreen mode

2. Use Validation Rules Effectively

Take advantage of Enforma's powerful validation system:

const validationRules = {
  // Array validation with wildcards
  'experience.*.company': 'required',
  'experience.*.startDate': 'required|date',

  // Cross-field validation with @ references
  'experience.*.endDate': 'date|date_after:@experience.*.startDate',

  // Conditional validation
  'billingAddress.street': 'required_unless:sameAsBilling,true',

  // Complex business rules
  'items.*.quantity': 'required|integer|min:1|max:@items.*.maxAvailable'
}
Enter fullscreen mode Exit fullscreen mode

Final Thoughts: Nested Forms Made Simple

Enforma transforms the traditionally complex challenge of nested forms into an elegant, maintainable solution. Whether you're building simple contact forms with address sections or complex multi-step applications with dynamic arrays, Enforma's approach provides:

✓ Clean, readable code with intuitive dot notation

✓ Powerful array management with automatic add/remove/reorder

✓ Robust validation including cross-field and nested rules

✓ Performance optimization for large, complex forms

✓ Flexible rendering options from headless to schema-driven

The result? You can focus on building great user experiences instead of wrestling with form state management. Your nested forms become a competitive advantage rather than a development bottleneck.

Ready to master nested forms? Install Enforma and transform your most complex forms into clean, maintainable code.

npm install @encolajs/enforma
Enter fullscreen mode Exit fullscreen mode

Get Started with Enforma →

Comments 0 total

    Add comment