Great question! You're not alone—integrating Inertia v2's <Form> component with non-native inputs like shadcn-vue's Checkbox requires some bridging since Inertia tracks input changes via native events, which shadcn components may not emit by default.
Here's how you can handle this pattern effectively:
1. Should I use <Form> or useForm()?
Both work, but when using non-native input components, you'll have to manually keep the form state in sync. The new <Form> is convenient for "native forms," but useForm() provides more granular control and might feel more natural when working with external UI component libraries (like shadcn).
tl;dr: Either works, but with custom components, useForm() is often clearer and less "magical."
2. Wrap shadcn components in a custom Field?
Recommended Pattern:
Yes, wrap your UI component (e.g., shadcn Checkbox) in a custom Field component that:
- syncs its value with your Inertia form state
- emits changes to Inertia so dirty state/validation remain accurate
3. The Example Pattern
Suppose you have this:
<template>
<Form :initial-values="{ agree: false }" @submit="submit">
<ShadcnCheckbox
:checked="form.values.agree"
@update:checked="form.setFieldValue('agree', $event)"
:error="form.errors.agree"
label="Accept terms"
/>
<button type="submit">Submit</button>
</Form>
</template>
<script setup>
import { Form } from '@inertiajs/vue3'
import { ShadcnCheckbox } from 'shadcn-vue'
function submit(values) {
// submit logic
}
</script>
But the form isn't dirty because ShadcnCheckbox doesn't emit native input/change events.
Solution:
Wrap shadcn components in a custom field that directly sets the form state:
<!-- CheckboxField.vue -->
<template>
<ShadcnCheckbox
:checked="checked"
@update:checked="updateValue"
v-bind="$attrs"
/>
<span v-if="error" class="error">{{ error }}</span>
</template>
<script setup>
import { computed } from 'vue'
import { ShadcnCheckbox } from 'shadcn-vue'
const props = defineProps({
modelValue: Boolean,
error: String,
})
const emits = defineEmits(['update:modelValue'])
const checked = computed({
get: () => props.modelValue,
set: (v) => emits('update:modelValue', v),
})
function updateValue(value) {
emits('update:modelValue', value)
}
</script>
Then in your form:
<template>
<Form :initial-values="{ agree: false }" v-slot="{ values, setFieldValue, errors }">
<CheckboxField
v-model="values.agree"
:error="errors.agree"
label="Accept terms"
/>
<button type="submit">Submit</button>
</Form>
</template>
Or, with useForm() (example):
<script setup>
import { useForm } from '@inertiajs/vue3'
import CheckboxField from './CheckboxField.vue'
const form = useForm({
agree: false,
})
function submit() {
form.post('/endpoint')
}
</script>
<template>
<form @submit.prevent="submit">
<CheckboxField
v-model="form.agree"
:error="form.errors.agree"
label="Accept terms"
/>
<button type="submit" :disabled="form.processing">Submit</button>
</form>
</template>
Key Points
- Encapsulate non-native logic in a custom Field that connects your UI component to the Inertia form state (
v-modelorsetFieldValueis key). - Use
v-modelor manually sync values and events between your Field wrapper and the Inertia form object. - Dirty state, validation, and form processing will now work as expected, even with custom UI components.
- Either
<Form>oruseFormworks. With non-native UI,useFormmay offer more transparency.
Summary
- Create Field wrappers for external UI components
- Sync values directly to your Inertia form state
- Propagate validation errors into your wrappers
This ensures Inertia's reactivity stays in sync and you get dirty state tracking, error display, and proper form behavior.
Let me know if you want a more concrete example!