UX patterns

Forms and actions

How a screen creates, edits and deletes records, and how it tells the user what happened.

Every change a user makes should feel the same, whichever module owns the record. Writes follow one path: a button opens a form, the form resolves with the saved record, and one composable per entity owns the toasts and the refresh. This page covers that path: create and edit forms, row menus, confirmations for destructive actions and feedback.

For: module authors and app developers who add a screen that changes data.

The screens that show the results are on Pages.

Create and edit

  • Slideover. Create and edit forms open in a slideover (FormSlideover) that holds a UForm with a zod schema.
  • Modal. A short yes or no choice is a modal (UModal).
  • Never inline. Never put a create form next to a table.
  • Open through an overlay. Open forms with useOverlay(). The overlay resolves with the saved record, or false when cancelled, so the caller can navigate to it.
  • Buttons. Cancel is a neutral outline button (color="neutral" variant="outline"). The primary action comes last. The slideover component sets up both for you.
  • Hints. A hint goes in UFormField's description, not in an extra paragraph.
  • Layout. Slideover forms stay vertical, with plain UFormFields. Inline settings forms in a SectionCard use horizontal FormRow rows. See Detail pages.

A create form opens in a slideover over the list, with Cancel and the primary action at the bottom:

An inline settings form puts label and control rows in a section card, with Save in the card's footer:

<!-- EventFormSlideover.vue -->
<script setup lang="ts">
const props = defineProps<{ event?: EventRow }>()
const emit = defineEmits<{ close: [saved: EventRow | false] }>()

const t = useModuleT('events')
const { busy, run } = useAction()
const state = reactive({ title: props.event?.title ?? '' })

const save = () => run(async () => {
  const saved = await $fetch<EventRow>(props.event ? `/api/admin/events/${props.event.id}` : '/api/admin/events', {
    method: props.event ? 'PATCH' : 'POST',
    body: state,
  })
  emit('close', saved)
}, { success: t('admin.saved', 'Event saved') })
</script>

<template>
  <FormSlideover
    form-id="event-form"
    :title="event ? t('admin.edit', 'Edit event') : t('admin.create', 'New event')"
    :submit-label="event ? t('admin.save', 'Save event') : t('admin.create', 'New event')"
    :loading="busy"
    @close="emit('close', false)"
  >
    <UForm id="event-form" :state="state" class="flex flex-col gap-4" @submit="save">
      <UFormField name="title" :label="t('admin.title', 'Title')" :description="t('admin.titleHint', 'Shown on the public page.')">
        <UInput v-model="state.title" />
      </UFormField>
    </UForm>
  </FormSlideover>
</template>

Create button

The primary create button has icon="i-lucide-plus". Its label names the object, such as "New event" or "Add user".

The page only calls actions.create(). The actions composable does the rest:

  1. It opens the FormSlideover.
  2. It navigates to the detail page, when the entity has one.
  3. It returns the record, or false when cancelled.

The page never navigates itself.

One actions composable per entity

Every entity has one actions composable, named use<Entity>Actions(), such as useUserActions, useEventActions or useFormActions. It keeps pages thin and every page consistent:

  • It owns create, edit and remove, the toasts and the refresh afterwards.
  • It returns menu(record) for the row menu. So every list and detail page offers the same actions in the same order.
  • It lives in the module that owns the entity, not in UI.

Sub-entities that have no page of their own, such as an event's ticket types and add-ons, get a composable of their own too. Its menus skip Open. useEventCatalogActions is the example.

// composables/useEventActions.ts
export function useEventActions() {
  const t = useModuleT('events')
  const overlay = useOverlay()
  const confirm = useConfirmDialog()
  const toast = useToast()
  const { localize } = useLocaleLink()

  async function create() {
    const saved = await overlay.create(EventFormSlideover).open()
    if (saved) await navigateTo(localize(`/admin/events/${saved.id}`))
  }

  async function remove(event: EventRow) {
    const confirmed = await confirm({
      title: t('admin.deleteTitle', 'Delete {name}?', { name: event.title }),
      description: t('admin.deleteBody', 'Its tickets and attendees are deleted too. This cannot be undone.'),
      confirmText: t('admin.delete', 'Delete event'),
      destructive: true,
    })
    if (!confirmed) return
    try {
      await $fetch(`/api/admin/events/${event.id}`, { method: 'DELETE' })
      toast.add({ title: t('admin.deleted', 'Event deleted'), description: event.title })
      await refreshNuxtData('events')
    }
    catch (e) {
      toast.add(errorToToast(e))
    }
  }

  const menu = (event: EventRow) => [
    [{ label: t('open', 'Open'), icon: 'i-lucide-arrow-right', to: localize(`/admin/events/${event.id}`) }],
    [{ label: t('admin.edit', 'Edit event'), icon: 'i-lucide-pencil', onSelect: () => overlay.create(EventFormSlideover).open({ event }) }],
    [{ label: t('admin.delete', 'Delete event'), icon: 'i-lucide-trash-2', color: 'error' as const, onSelect: () => remove(event) }],
  ]

  return { create, remove, menu }
}

Row actions

Row actions go in a row menu (RowMenu, the ⋯ button) at the end of the row. There are no loose icon buttons in rows. The menu has its groups in this order:

  1. Open first, with "View public page" when there is one.
  2. The edits and extras.
  3. The destructive action, in its own group, coloured red.
ActionIcon
Openarrow-right
Editpencil
Duplicatecopy
Archivearchive
Deletetrash-2
  • Detail pages build the menu with open: false, because they are already there.
  • Sub-entities skip Open.
  • Pass subject, so the button's accessible name reads "Actions for Ada Lovelace".

Destructive actions

A destructive action asks for confirmation with useConfirmDialog({ destructive: true }), never with window.confirm. The dialog says three things:

  • The title asks about the record by name: "Delete Spring Gala?"
  • The description says what else is affected: "Its tickets and attendees are deleted too."
  • The confirm button names the verb and the object: "Delete event".

Feedback and errors

  • Success. A successful action is a toast with a title. When useful, the record's name is the description.
  • Failure. A failed action is a toast built with errorToToast(e), or useErrorToast() for a ready-made function. Both read the server's message from the usual places and fall back to a translated generic message.
  • Blocking problems. A problem that blocks the page, such as not found or forbidden, is a UEmpty, not a toast.
useAction() wraps the common "busy, try, toast on error" block. Use it instead of writing that block again.

A page that needs context from another page reads it from the URL, or opens the right form directly. Two examples:

  • "Add ticket type" on an event opens the form with the event already selected.
  • A ⌘K entry links to ?application=<id>.

Do not pass state through stores or history.state.

Next steps

  • Text and formatting – word the buttons, toasts and dialogs on this page.
  • UI composables – look up useConfirmDialog, useAction and useErrorToast.
  • Pages – place the row menu and the create button on a list or detail page.
Copyright © 2026