Form Patterns
Form Patterns
Learn how to work with Nuxt Crouton's form system - from adding custom fields and validation to understanding the underlying architecture.
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...
}
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
})
.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')
})
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']
})
.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
- User clicks "Create" button
open('create', 'users', [])- Form.vue renders slideover
- FormDynamicLoader loads UsersForm.vue
- User fills form fields
handleSubmit()callscreate(data)- API call succeeds
- Cache invalidated
- Form closes automatically
- Toast notification shown
Update Workflow
- User clicks edit button
open('update', 'users', ['user-123'])- Form pre-populated with
activeItemdata - User modifies fields
handleSubmit()callsupdate(id, data)- Optimistic update in cache
- Form closes
- Table row updates
- Toast notification shown
Nested Creation Workflow
Example: Creating a product and adding a new category inline
open('create', 'products', [])→ Product form (Level 1)- User fills product fields
- User clicks "+ Create new" in category dropdown
open('create', 'categories', [])→ Category form (Level 2, nested)- User creates category
- Category form closes, new category auto-selected
- User completes product form
- 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:
- Don't remove generated fields unless you're sure
- Add custom fields after generated ones for clarity
- Keep the generated submit handler and button
- 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:
- Check collection name matches exactly
- Run generator:
npx crouton-generate config crouton.config.js - Check component exists:
components/[Collection]Form.vue - Check browser console for errors
Form submits but nothing happens
Symptoms: No error, no success toast, form doesn't close
Solutions:
- Check API returns 200/201 status
- Check mutation composable setup
- Check
close()is called after success - Check cache invalidation in devtools
Validation errors not showing
Symptoms: Form submits with invalid data
Solutions:
- Check schema is defined
- Check
@errorhandler attached - Check field names match schema keys
- 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.