Patterns

Form Patterns

Working with forms in Nuxt Crouton - custom fields, validation, relation dropdowns, and architecture

Form Patterns

Learn how to work with Nuxt Crouton's form system - from adding custom fields and validation to understanding the underlying architecture.

Complete CroutonForm API Reference: For comprehensive form component documentation including all props, slots, and events, see Form Components API Reference.
For general form concepts and Nuxt UI form components, see the Nuxt UI Forms documentation.

Adding Custom Fields

Generated forms provide a foundation - you extend them by adding custom fields as needed.

Basic Custom Field

Add a category dropdown to a product form:

<!-- layers/shop/components/products/Form.vue -->
<script setup lang="ts">
// Keep generated form setup
const props = defineProps<ShopProductsFormProps>()
const { create, update } = useCollectionMutation('shopProducts')

// Add: Fetch related collection for dropdown
const { items: categories } = await useCollectionQuery('shopCategories')  // See /fundamentals/querying for query patterns
</script>

<template>
  <UForm :state="state" :schema="schema" @submit="handleSubmit">
    <!-- Generated fields -->
    <UFormField label="Name" name="name">
      <UInput v-model="state.name" />
    </UFormField>

    <UFormField label="Price" name="price">
      <UInput v-model.number="state.price" type="number" />
    </UFormField>

    <!-- Custom field: Category dropdown -->
    <UFormField label="Category" name="categoryId">
      <USelectMenu
        v-model="state.categoryId"
        :options="categories"
        option-attribute="name"
        value-attribute="id"
        placeholder="Select category"
      />
    </UFormField>

    <CroutonButton :action="action" :loading="loading" />
  </UForm>
</template>

Searchable Dropdown

For large datasets, add search functionality:

<script setup lang="ts">
const { items: categories } = await useCollectionQuery('shopCategories')
const searchQuery = ref('')

const filteredCategories = computed(() =>
  categories.value.filter(c =>
    c.name.toLowerCase().includes(searchQuery.value.toLowerCase())
  )
)  // For advanced filtering, see /fundamentals/querying
</script>

<template>
  <UFormField label="Category" name="categoryId">
    <UInput v-model="searchQuery" placeholder="Search categories..." class="mb-2" />
    <USelectMenu
      v-model="state.categoryId"
      :options="filteredCategories"
      option-attribute="name"
      value-attribute="id"
    />
  </UFormField>
</template>

Handle Loading States

Show loading indicators while fetching data:

<script setup lang="ts">
const { items: categories, pending: loadingCategories } = await useCollectionQuery('shopCategories')
</script>

<template>
  <UFormField label="Category" name="categoryId">
    <USelectMenu
      v-model="state.categoryId"
      :options="categories"
      :loading="loadingCategories"
    />
  </UFormField>
</template>

Validation Patterns

Nuxt Crouton uses Zod for schema validation.

Basic Validation Rules

// layers/shop/composables/useProducts.ts
import { z } from 'zod'

export function useShopProducts() {
  const schema = z.object({
    // Generated fields
    name: z.string().min(1, 'Name is required'),
    price: z.number().min(0, 'Price must be positive'),

    // Custom validation rules
    sku: z.string()
      .regex(/^[A-Z]{3}-\d{4}$/, 'SKU must be format: ABC-1234'),

    email: z.string()
      .email('Must be a valid email address'),

    url: z.string()
      .url('Must be a valid URL')
      .optional(),

    quantity: z.number()
      .int('Must be a whole number')
      .min(0)
      .max(9999, 'Quantity cannot exceed 9999')
  })

  // Rest of composable...
}
Zod Validation: Learn more about Zod's validation methods in the Zod documentation.

Cross-Field Validation

Validate relationships between multiple fields:

const schema = z.object({
  price: z.number().min(0, 'Price must be positive'),
  discountPrice: z.number().optional()
}).refine((data) => {
  // Ensure discount is less than regular price
  if (data.discountPrice && data.discountPrice >= data.price) {
    return false
  }
  return true
}, {
  message: 'Discount price must be less than regular price',
  path: ['discountPrice']  // Show error on discountPrice field
})
Zod Refine: Learn more about cross-field validation with .refine() in the Zod documentation.

Async Validation

Validate against your database or external APIs:

const schema = z.object({
  name: z.string().min(1),

  // Check if SKU already exists
  sku: z.string().refine(async (sku) => {
    const exists = await $fetch(`/api/products/check-sku?sku=${sku}`)
    return !exists
  }, 'SKU already exists'),

  // Validate against external API
  domain: z.string().refine(async (domain) => {
    const isValid = await $fetch(`/api/verify-domain?domain=${domain}`)
    return isValid
  }, 'Domain is not accessible')
})
Async validation can slow down your forms. Use it sparingly and consider debouncing user input.

Conditional Validation

Apply different rules based on field values:

const schema = z.object({
  type: z.enum(['physical', 'digital']),
  weight: z.number().optional(),
  downloadUrl: z.string().optional()
}).refine((data) => {
  // Physical products must have weight
  if (data.type === 'physical' && !data.weight) {
    return false
  }
  return true
}, {
  message: 'Weight is required for physical products',
  path: ['weight']
}).refine((data) => {
  // Digital products must have download URL
  if (data.type === 'digital' && !data.downloadUrl) {
    return false
  }
  return true
}, {
  message: 'Download URL is required for digital products',
  path: ['downloadUrl']
})
Conditional Validation: For more patterns with .refine() and conditional logic, see the Zod documentation.

Form System Architecture

Understanding how Nuxt Crouton's form system works helps you customize it effectively.

Component Hierarchy

User Action (Click "Create")
  ↓
useCrouton().open('create', 'users', [])
  ↓
Form.vue (Global Container)
  ├─ Renders: Modal | Slideover | Dialog
  ├─ Manages: State via useCrouton()
  └─ Delegates to: FormDynamicLoader
      ↓
FormDynamicLoader.vue
  ├─ Resolves: collection → component mapping
  └─ Loads: [Collection]Form.vue
      ↓
UsersForm.vue (Generated)
  ├─ Uses: FormLayout for structure
  ├─ Uses: UForm for validation
  ├─ Renders: Field components
  └─ Submits: via useCollectionMutation()
      ↓
Data Layer (useCollectionMutation)
  ├─ create() / update() / deleteItems()
  ├─ Optimistic updates
  ├─ Cache invalidation
  └─ Toast notifications

State Management

Global State (useCrouton):

const { open, close, closeAll, croutonStates } = useCrouton()

// Open form
open('create', 'users', [])                    // Create in slideover
open('update', 'users', ['user-123'], 'modal') // Edit in modal
open('delete', 'users', ['id1', 'id2'])        // Delete confirmation
open('view', 'users', ['user-123'])            // View-only

// Close forms
close()      // Close current
closeAll()   // Close all forms

Local Form State (per component):

<script setup lang="ts">
const props = defineProps<{
  action: 'create' | 'update' | 'delete'
  loading: string
  activeItem?: any
}>()

// Local reactive state
const state = ref({
  id: props.activeItem?.id || null,
  name: props.activeItem?.name || '',
  email: props.activeItem?.email || '',
})

// Validation schema
const schema = z.object({
  name: z.string().min(1),
  email: z.string().email()
})

// Form submission
const handleSubmit = async () => {
  const { create, update } = useCollectionMutation('users')

  if (props.action === 'create') {
    await create(state.value)
  } else if (props.action === 'update') {
    await update(state.value.id, state.value)
  }

  close()
}
</script>

Container Types

Modal - Standard forms, simple edits:

  • Centered on screen
  • Backdrop overlay
  • Single instance recommended

Slideover - Complex forms, nested workflows:

  • Side panel from right
  • Supports up to 5 levels of nesting
  • Expandable to fullscreen
  • Breadcrumb navigation

Dialog - Simple confirmations:

  • Minimal UI
  • Typically for destructive actions

Specialized Components

FormReferenceSelect - Select related entities:

<CroutonFormReferenceSelect
  v-model="state.categoryId"
  collection="categories"
  :multiple="false"
/>

Features: Single/multi-select, searchable, inline creation, auto-selection

FormRepeater - Manage arrays of structured data:

<CroutonFormRepeater
  v-model="state.contacts"
  :component="ContactItem"
/>

Features: Add/remove, drag-to-reorder, dynamic components

FormDependentFieldLoader - Conditional fields:

<CroutonFormDependentFieldLoader
  :depends-on="['category']"
  :values="{ category: state.category }"
  component-path="CategorySpecificFields"
/>

Common Workflows

Create Workflow

  1. User clicks "Create" button
  2. open('create', 'users', [])
  3. Form.vue renders slideover
  4. FormDynamicLoader loads UsersForm.vue
  5. User fills form fields
  6. handleSubmit() calls create(data)
  7. API call succeeds
  8. Cache invalidated
  9. Form closes automatically
  10. Toast notification shown

Update Workflow

  1. User clicks edit button
  2. open('update', 'users', ['user-123'])
  3. Form pre-populated with activeItem data
  4. User modifies fields
  5. handleSubmit() calls update(id, data)
  6. Optimistic update in cache
  7. Form closes
  8. Table row updates
  9. Toast notification shown

Nested Creation Workflow

Example: Creating a product and adding a new category inline

  1. open('create', 'products', []) → Product form (Level 1)
  2. User fills product fields
  3. User clicks "+ Create new" in category dropdown
  4. open('create', 'categories', []) → Category form (Level 2, nested)
  5. User creates category
  6. Category form closes, new category auto-selected
  7. User completes product form
  8. Product created with new category

Advanced Patterns

Dependent Fields

Show different fields based on selected category:

<template>
  <!-- Category selector -->
  <UFormField label="Category" name="category">
    <CroutonFormReferenceSelect
      v-model="state.category"
      collection="categories"
    />
  </UFormField>

  <!-- Dependent field loader -->
  <CroutonFormDependentFieldLoader
    :depends-on="['category']"
    :values="{ category: state.category }"
    component-path="CategorySpecificFields"
  />
</template>

Multi-Step Forms

Use tabs with validation per step:

<script setup lang="ts">
const activeSection = ref('step1')

const navigationItems = [
  { label: 'Basic Info', value: 'step1' },
  { label: 'Details', value: 'step2' },
  { label: 'Review', value: 'step3' }
]
</script>

<template>
  <CroutonFormLayout
    :tabs="true"
    :navigation-items="navigationItems"
    v-model="activeSection"
  >
    <!-- Step content with v-show -->
  </CroutonFormLayout>
</template>

Validation Error Tracking

Track errors per tab:

// Field → Tab mapping
const fieldToGroup: Record<string, string> = {
  'name': 'general',
  'email': 'general',
  'metaTitle': 'seo',
  'metaDescription': 'seo'
}

const validationErrors = ref<any[]>([])

const handleValidationError = (event: any) => {
  if (event?.errors) {
    validationErrors.value = event.errors
  }
}

// Count errors per tab
const tabErrorCounts = computed(() => {
  const counts: Record<string, number> = {}
  validationErrors.value.forEach(error => {
    const tabName = fieldToGroup[error.name] || 'general'
    counts[tabName] = (counts[tabName] || 0) + 1
  })
  return counts
})

Best Practices

Keep Generated Code Intact

When adding custom fields:

  1. Don't remove generated fields unless you're sure
  2. Add custom fields after generated ones for clarity
  3. Keep the generated submit handler and button
  4. Comment your customizations for future reference
<template>
  <UForm>
    <!-- Generated fields -->
    <UFormField label="Name" name="name">
      <UInput v-model="state.name" />
    </UFormField>

    <!-- Custom fields - Added for category support -->
    <UFormField label="Category" name="categoryId">
      <USelectMenu v-model="state.categoryId" :options="categories" />
    </UFormField>

    <!-- Keep generated button -->
    <CroutonButton :action="action" :loading="loading" />
  </UForm>
</template>

Update TypeScript Types

Add custom fields to your type definitions:

// layers/shop/types/products.ts
export interface ShopProduct {
  // Generated fields
  id: string
  name: string
  price: number

  // Custom fields
  categoryId?: string
  sku?: string
  tags?: string[]
}

Performance Considerations

For large forms (50+ fields):

  • Use tabs to organize into sections
  • Lazy load heavy components
  • Debounce auto-save operations
  • Optimize repeaters with virtual scrolling

For nested forms:

  • Limit nesting to 2-3 levels for better UX
  • Use breadcrumbs clearly
  • Consider alternative UX (modal for simple edits)
  • Add "Back" buttons with context

Cache management:

  • Forms auto-invalidate cache on success
  • No manual refresh needed
  • List refreshes automatically

Troubleshooting

Form doesn't open

Symptoms: Clicking create/edit does nothing

Solutions:

  1. Check collection name matches exactly
  2. Run generator: npx crouton-generate config crouton.config.js
  3. Check component exists: components/[Collection]Form.vue
  4. Check browser console for errors

Form submits but nothing happens

Symptoms: No error, no success toast, form doesn't close

Solutions:

  1. Check API returns 200/201 status
  2. Check mutation composable setup
  3. Check close() is called after success
  4. Check cache invalidation in devtools

Validation errors not showing

Symptoms: Form submits with invalid data

Solutions:

  1. Check schema is defined
  2. Check @error handler attached
  3. Check field names match schema keys
  4. Check UForm wraps all fields

Summary

Nuxt Crouton's form system provides:

  • Automatic form generation from schemas
  • Multiple container types (modal/slideover/dialog)
  • Dynamic component loading per collection
  • Nested form support (up to 5 levels)
  • Validation tracking with visual indicators
  • Specialized components (references, repeaters, dependent fields)
  • Optimistic updates with cache invalidation
  • Responsive layouts with tabs and sidebar
  • Error handling with user-friendly messages

For most use cases, the generated forms "just work". For advanced scenarios, every piece is customizable while maintaining the core architecture.