Create And Update With One Modal
A create form and an edit form often contain the same fields. Instead of maintaining two components, use one feature modal and let the selected record determine the mode:
user === nullmeans create.user !== nullmeans update.- The page owns the open state and selected user.
- The modal owns the editable form draft.
This example assumes the application already has an accessible BaseModal component. The base component is responsible for focus management, keyboard interaction, the backdrop, and rendering the dialog. The feature modal is responsible for the user form.
Page State
Keep a single modal instance outside the list. Store the selected user separately from the open state.
<script setup lang="ts">
import { ref } from 'vue'
import UserFormModal from '@/components/users/UserFormModal.vue'
type User = {
id: number
name: string
email: string
}
defineProps<{
users: User[]
}>()
const isUserModalOpen = ref(false)
const selectedUser = ref<User | null>(null)
function openCreateModal() {
selectedUser.value = null
isUserModalOpen.value = true
}
function openEditModal(user: User) {
selectedUser.value = user
isUserModalOpen.value = true
}
function closeUserModal() {
isUserModalOpen.value = false
selectedUser.value = null
}
</script>
<template>
<button type="button" @click="openCreateModal">
Create user
</button>
<ul>
<li v-for="user in users" :key="user.id">
<span>{{ user.name }}</span>
<button type="button" @click="openEditModal(user)">
Edit
</button>
</li>
</ul>
<UserFormModal
:open="isUserModalOpen"
:user="selectedUser"
@close="closeUserModal"
/>
</template>Set selectedUser before opening the modal. This guarantees that the form receives the correct record when its opening watcher runs.
Feature Modal
The modal derives its title, button label, and submission route from user. It copies editable values into useForm instead of binding inputs directly to the prop.
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useForm } from '@inertiajs/vue3'
import { store, update } from '@/routes/users'
import BaseModal from '@/components/BaseModal.vue'
type User = {
id: number
name: string
email: string
}
const props = defineProps<{
open: boolean
user: User | null
}>()
const emit = defineEmits<{
close: []
}>()
const form = useForm({
name: '',
email: '',
})
const isEditing = computed(() => props.user !== null)
const title = computed(() => isEditing.value ? 'Edit user' : 'Create user')
const submitLabel = computed(() => isEditing.value ? 'Update user' : 'Create user')
watch(
() => props.open,
(open) => {
if (!open) {
return
}
form.defaults({
name: props.user?.name ?? '',
email: props.user?.email ?? '',
})
form.resetAndClearErrors()
},
{ immediate: true },
)
function close() {
if (form.processing) {
return
}
emit('close')
}
function submit() {
if (form.processing) {
return
}
const action = props.user === null
? store()
: update(props.user.id)
form.submit(action, {
preserveScroll: true,
onSuccess: () => emit('close'),
})
}
</script>
<template>
<BaseModal
:open="open"
:title="title"
@close="close"
>
<form @submit.prevent="submit">
<div>
<label for="user-name">Name</label>
<input
id="user-name"
v-model="form.name"
type="text"
autocomplete="name"
:aria-invalid="Boolean(form.errors.name)"
:aria-describedby="form.errors.name ? 'user-name-error' : undefined"
>
<p
v-if="form.errors.name"
id="user-name-error"
role="alert"
>
{{ form.errors.name }}
</p>
</div>
<div>
<label for="user-email">Email</label>
<input
id="user-email"
v-model="form.email"
type="email"
autocomplete="email"
:aria-invalid="Boolean(form.errors.email)"
:aria-describedby="form.errors.email ? 'user-email-error' : undefined"
>
<p
v-if="form.errors.email"
id="user-email-error"
role="alert"
>
{{ form.errors.email }}
</p>
</div>
<div>
<button
type="button"
:disabled="form.processing"
@click="close"
>
Cancel
</button>
<button type="submit" :disabled="form.processing">
{{ form.processing ? 'Saving...' : submitLabel }}
</button>
</div>
<p aria-live="polite">
{{ form.processing ? 'Saving user' : '' }}
</p>
</form>
</BaseModal>
</template>Wayfinder provides the method and URL for store() and update(). Therefore, the same form.submit() call sends POST for create and PUT or PATCH for update, according to the Laravel route definition.
Without Wayfinder, use the explicit Inertia methods instead:
const options = {
preserveScroll: true,
onSuccess: () => emit('close'),
}
if (props.user === null) {
form.post('/users', options)
} else {
form.patch(`/users/${props.user.id}`, options)
}Reset On Every Open
The component usually remains mounted when the modal closes. Without an explicit reset, values and validation errors from one session can appear in the next one.
The opening watcher intentionally runs these operations in order:
defaults()stores the values for the current user, or empty values for create.resetAndClearErrors()restores those defaults and removes errors from the previous session.
Do not continuously watch props.user and copy every change into the form. A partial reload could then overwrite fields while the user is typing. Hydrate the form when the modal opens instead.
Submission Behaviour
- Disable both action buttons while
form.processingis true. - Keep the guard inside
submit()because a form can also be submitted with Enter. - Close only in
onSuccess;onFinishalso runs after validation errors. - Keep the modal open on validation errors so the user can correct the fields.
- Do not clear errors in
onError. - Prevent closing while processing, or explicitly call
form.cancel()before closing.
The example prevents closing while processing. This avoids an old request completing after another user has already been opened in the same modal.
Base Modal Contract
BaseModal should provide the dialog behaviour shared by every feature modal:
- Render outside clipping containers, usually with
Teleport. - Give the dialog an accessible name, such as with
aria-labelledby. - Move focus inside when it opens and trap focus while open.
- Close on Escape and backdrop interaction when closing is allowed.
- Restore focus to the trigger when it closes.
- Make the background inert and lock page scrolling.
- Expose a visible close or cancel action.
Adding only role="dialog" and aria-modal="true" does not implement these behaviours. Prefer a tested dialog primitive inside BaseModal instead of recreating focus management in every feature modal.
Common Mistakes
Mutating The Prop
Do not use v-model="user.name". Objects are passed by reference, so typing in the form can change the page before the request succeeds. Keep a local useForm draft.
Rendering One Modal Per Row
Do not place UserFormModal inside v-for. Keep one modal on the page and pass the selected record to it.
Reusing Old Defaults
Calling only reset() can restore values from the first record opened. Update the defaults before resetting.
Closing Too Early
Do not emit close immediately after calling submit(). Wait for onSuccess, otherwise validation errors arrive in a hidden modal.