Guides

Forms

Explain create, edit, show, and select controllers and their page-level data flow.

This page is about page workflows, not form UI libraries or field validation.

Controllers connect resource resolution, low-level query or mutation composables, and navigation into one place. They are most useful when a page follows a familiar CRUD shape and you want the page logic to stay thin.

Route-aware inference is optional. If you pass resource or id yourself, the controller can still work without router context.

ControllerBest fitBuilt from
useCreatecreate pages and create dialogsuseCreateOne
useEditedit pages and embedded editorsuseGetOne, useUpdateOne
useShowread-only detail pagesuseGetOne
useSelectremote select-like inputsuseGetList, useGetMany

The main value is inference and composition. resource, fetcherName, id, and redirect can often be resolved for you, while the page still keeps control over local form state.

Create

useCreate is the controller for a standard create page.

It wraps useCreateOne, then adds page-level concerns such as resource inference and redirect handling.

It returns useCreateOne result and some extras.

ExtraMeaning
saveSubmit form data and run redirect logic.
isLoadingPending state for the create page.

Resource

props.resource wins first.

If you omit it, useCreate tries to resolve the current resource from resource definitions and the current route. fetcherName follows the same rule: props.fetcherName wins first, then resource meta.

Route-aware example:

<script setup lang="ts">
import { useCreate } from '@ginjou/vue'

const createPage = useCreate()
</script>

Manual example:

<script setup lang="ts">
import { useCreate } from '@ginjou/vue'

const createPage = useCreate({
    resource: 'posts',
    fetcherName: 'legacy',
})
</script>

Use the route-aware form only when the page already lives inside matching resource and router setup.

For dialogs, wizards, and nested create flows, explicit props are usually clearer.

Redirect

The default redirect is list.

You can override it with a resource action, custom navigation props, or a function that receives mutation data.

You can also keep redirect next to the controller setup in the Resource examples above, so resource, fetcher, and redirect stay in one place.

Go to show:

<script setup lang="ts">
import { useCreate } from '@ginjou/vue'

const createPage = useCreate({
    resource: 'posts',
    redirect: 'show',
})
</script>

Custom destination:

<script setup lang="ts">
import { useCreate } from '@ginjou/vue'

const createPage = useCreate({
    resource: 'posts',
    redirect: result => ({
        to: `/review/${result.data.id}`,
    }),
})
</script>

Without router context, the mutation still runs, but the redirect step becomes a no-op.

Submit Form Data

Call save(formData) when the form submits.

save runs the create mutation first. When the mutation succeeds, it resolves the redirect target.

<script setup lang="ts">
import { useCreate } from '@ginjou/vue'
import { reactive } from 'vue'

const formData = reactive({
    title: '',
    status: 'draft',
})

const createPage = useCreate({
    resource: 'posts',
})

async function submit() {
    await createPage.save({ ...formData })
}
</script>

<template>
    <form @submit.prevent="submit">
        <input v-model="formData.title">
        <select v-model="formData.status">
            <option value="draft">
                Draft
            </option>
            <option value="published">
                Published
            </option>
        </select>
        <button :disabled="createPage.isLoading">
            {{ createPage.isLoading ? 'Saving...' : 'Create' }}
        </button>
    </form>
</template>

Edit

useEdit is the controller for a standard edit page.

It combines useGetOne and useUpdateOne, so one composable can load the current record and save the next version.

Use it when the page edits an existing record and should treat loading and saving as one workflow.

ExtraMeaning
recordThe current server record.
queryThe full useGetOne result.
saveSubmit edited data and run redirect logic.
isLoadingCombined query and mutation loading state.

Resource / ID

props.resource and props.id win first.

If you omit them, useEdit tries to read them from the resolved resource and the current edit route. fetcherName also follows props.fetcherName first, then resource meta.

Route-aware example:

<script setup lang="ts">
import { useEdit } from '@ginjou/vue'

const editPage = useEdit()
</script>

Manual example:

<script setup lang="ts">
import { useEdit } from '@ginjou/vue'

const editPage = useEdit({
    resource: 'posts',
    id: 42,
})
</script>

Use the manual form for dialogs, embedded editors, or any page that does not depend on route inference.

Source Record

queryMeta and queryOptions go to the internal useGetOne call.

Use them when the source record needs extra headers, query control, or conditional loading.

Default query:

<script setup lang="ts">
import { useEdit } from '@ginjou/vue'

const editPage = useEdit({
    resource: 'posts',
    id: 1,
})
</script>

Custom query:

<script setup lang="ts">
import { useEdit } from '@ginjou/vue'
import { computed, ref } from 'vue'

const token = ref('token')
const ready = computed(() => true)

const editPage = useEdit({
    resource: 'posts',
    id: 1,
    queryMeta: {
        headers: {
            Authorization: `Bearer ${token.value}`,
        },
    },
    queryOptions: {
        enabled: ready,
    },
})
</script>

Mutation Mode

useEdit passes mutationMode to the internal update mutation.

The main page-level difference is redirect timing.

ModeRedirect behavior
pessimisticWait for successful mutation, then redirect.
optimisticSchedule redirect without waiting for the server response.
undoableSchedule redirect inside the undoable flow.

The default is pessimistic.

That is the safest choice for edit screens because the page does not navigate away until the server accepts the change.

<script setup lang="ts">
import { useEdit } from '@ginjou/vue'

const editPage = useEdit({
    resource: 'posts',
    id: 1,
    mutationMode: 'undoable',
    undoableTimeout: 8000,
})
</script>

Redirect

The default redirect is show.

You can change it to list or a custom destination.

You can also keep redirect next to the controller setup in the Resource examples above, so resource, id, and redirect stay together.

Go to list:

<script setup lang="ts">
import { useEdit } from '@ginjou/vue'

const editPage = useEdit({
    resource: 'posts',
    id: 1,
    redirect: 'list',
})
</script>

Custom destination:

<script setup lang="ts">
import { useEdit } from '@ginjou/vue'

const editPage = useEdit({
    resource: 'posts',
    id: 1,
    redirect: { to: '/posts' },
})
</script>

Submit Form Data

Keep server state and editable form state separate.

Read the loaded record, copy it into local form state, then call save(formData).

Keep server state and editable state separate so the form can change freely without mutating the cached record object in place.

<script setup lang="ts">
import { useEdit } from '@ginjou/vue'
import { reactive, watch } from 'vue'

const editPage = useEdit({
    resource: 'posts',
    id: 1,
})

const formData = reactive({
    title: '',
    status: 'draft',
})

watch(editPage.record, (record) => {
    if (!record)
        return

    formData.title = record.title ?? ''
    formData.status = record.status ?? 'draft'
}, { immediate: true })

async function submit() {
    await editPage.save({ ...formData })
}
</script>

<template>
    <form v-if="editPage.record" @submit.prevent="submit">
        <input v-model="formData.title">
        <select v-model="formData.status">
            <option value="draft">
                Draft
            </option>
            <option value="published">
                Published
            </option>
        </select>
        <button :disabled="editPage.isLoading">
            {{ editPage.isLoading ? 'Saving...' : 'Update' }}
        </button>
    </form>
</template>

Show

useShow is the controller for a read-only detail page.

It is the lightest controller in this group: mostly useGetOne plus resource and id resolution.

It returns useGetOne result and some extras.

ExtraMeaning
idThe resolved id ref used by the page.
<script setup lang="ts">
import { useShow } from '@ginjou/vue'

const showPage = useShow({
    resource: 'posts',
    id: 1,
})
</script>

<template>
    <div v-if="showPage.isLoading">
        Loading...
    </div>
    <div v-else-if="!showPage.record">
        Not found.
    </div>
    <article v-else>
        <h1>{{ showPage.record.title }}</h1>
        <p>{{ showPage.record.status }}</p>
    </article>
</template>

Resource / ID

props.id wins first.

If props.resource matches the inferred resource, useShow can fall back to the route show id. If props.resource points to a different resource, useShow trusts only props.id.

Route-aware example:

<script setup lang="ts">
import { useShow } from '@ginjou/vue'

const showPage = useShow()
</script>

Manual example:

<script setup lang="ts">
import { useShow } from '@ginjou/vue'

const showPage = useShow({
    resource: 'posts',
    id: 42,
})
</script>

Select

useSelect is for remote select inputs.

It combines useGetList and useGetMany into one composable.

This is a data helper, not a rendered select component. Bring your own UI and let the controller provide option state, search state, and selected value hydration.

The result returns useGetList result and some extras.

ExtraMeaning
optionsMerged option list for the field.
searchCurrent search text.
currentPageCurrent page ref.
perPagePage size ref.

Option List

options is built from two sources.

The first source is the current list data from useGetList.

The second source is the selected value data from useGetMany.

These two sources are merged, so selected items can still appear even when they are not in the current list page.

Use labelKey to choose what users see.

Use valueKey to choose what the field stores.

If you do not set them, useSelect uses title for labels and id for values.

<script setup lang="ts">
import { useSelect } from '@ginjou/vue'

const authorSelect = useSelect({
    resource: 'authors',
    labelKey: 'profile.name',
    valueKey: 'uuid',
})
</script>

search becomes filters.

By default, useSelect builds a contains filter from labelKey. You can override that with searchToFilters.

<script setup lang="ts">
import { FilterOperator } from '@ginjou/core'
import { useSelect } from '@ginjou/vue'

const categorySelect = useSelect({
    resource: 'categories',
    labelKey: 'name',
    searchToFilters(value) {
        if (!value)
            return []

        return [
            {
                field: 'name',
                operator: FilterOperator.contains,
                value,
            },
        ]
    },
})
</script>

<template>
    <input v-model="categorySelect.search">
</template>

Pagination

currentPage and perPage drive the internal list query.

Update these refs when the user moves between pages.

<script setup lang="ts">
import { useSelect } from '@ginjou/vue'

const categorySelect = useSelect({
    resource: 'categories',
    labelKey: 'name',
    pagination: {
        current: 1,
        perPage: 10,
    },
})

function nextPage() {
    categorySelect.currentPage.value = (categorySelect.currentPage.value ?? 1) + 1
}

function prevPage() {
    categorySelect.currentPage.value = Math.max((categorySelect.currentPage.value ?? 1) - 1, 1)
}
</script>

<template>
    <select>
        <option
            v-for="option in categorySelect.options"
            :key="option.value"
            :value="option.value"
        >
            {{ option.label }}
        </option>
    </select>
    <button @click="prevPage">
        Prev
    </button>
    <button @click="nextPage">
        Next
    </button>
</template>

Value Resolution

value is converted to ids and sent to the internal useGetMany call.

That is why selected items can still appear even when they are not part of the current list page.

This is the main reason to prefer useSelect over wiring a plain useGetList manually.

<script setup lang="ts">
import { useSelect } from '@ginjou/vue'

const categorySelect = useSelect({
    resource: 'categories',
    labelKey: 'name',
    value: [2, 10],
    pagination: {
        current: 1,
        perPage: 10,
    },
})
</script>

Delete

There is no delete controller in the Vue controllers package.

For delete flows, use useDeleteOne or useDeleteMany directly and compose the page behavior yourself.

That is intentional: delete behavior is usually embedded inside list, show, or edit pages rather than modeled as a standalone controller.

If the page needs redirect or undoable UX, keep that logic in page code.

<script setup lang="ts">
import { useDeleteOne } from '@ginjou/vue'

const deletePost = useDeleteOne({
    resource: 'posts',
    id: 1,
    mutationMode: 'undoable',
})

async function remove() {
    await deletePost.mutateAsync()
}
</script>
Copyright © 2026