Forms
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.
| Controller | Best fit | Built from |
|---|---|---|
useCreate | create pages and create dialogs | useCreateOne |
useEdit | edit pages and embedded editors | useGetOne, useUpdateOne |
useShow | read-only detail pages | useGetOne |
useSelect | remote select-like inputs | useGetList, 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.
| Extra | Meaning |
|---|---|
save | Submit form data and run redirect logic. |
isLoading | Pending 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>
<script lang="ts">
import { useCreate } from '@ginjou/svelte'
const createPage = useCreate()
</script>
Manual example:
<script setup lang="ts">
import { useCreate } from '@ginjou/vue'
const createPage = useCreate({
resource: 'posts',
fetcherName: 'legacy',
})
</script>
<script lang="ts">
import { useCreate } from '@ginjou/svelte'
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.
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>
<script lang="ts">
import { useCreate } from '@ginjou/svelte'
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>
<script lang="ts">
import { useCreate } from '@ginjou/svelte'
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>
<script lang="ts">
import { useCreate } from '@ginjou/svelte'
let title = $state('')
let status = $state('draft')
const createPage = useCreate({
resource: 'posts',
})
async function submit(e: SubmitEvent) {
e.preventDefault()
await createPage.save({ title, status })
}
</script>
<form onsubmit={submit}>
<input bind:value={title}>
<select bind:value={status}>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
<button disabled={createPage.isLoading}>
{createPage.isLoading ? 'Saving...' : 'Create'}
</button>
</form>
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.
| Extra | Meaning |
|---|---|
record | The current server record. |
query | The full useGetOne result. |
save | Submit edited data and run redirect logic. |
isLoading | Combined 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>
<script lang="ts">
import { useEdit } from '@ginjou/svelte'
const editPage = useEdit()
</script>
Manual example:
<script setup lang="ts">
import { useEdit } from '@ginjou/vue'
const editPage = useEdit({
resource: 'posts',
id: 42,
})
</script>
<script lang="ts">
import { useEdit } from '@ginjou/svelte'
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>
<script lang="ts">
import { useEdit } from '@ginjou/svelte'
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>
<script lang="ts">
import { useEdit } from '@ginjou/svelte'
let token = $state('token')
let ready = $state(true)
const editPage = useEdit(() => ({
resource: 'posts',
id: 1,
queryMeta: {
headers: {
Authorization: `Bearer ${token}`,
},
},
queryOptions: {
enabled: ready,
},
}))
</script>
Mutation Mode
useEdit passes mutationMode to the internal update mutation.
The main page-level difference is redirect timing.
| Mode | Redirect behavior |
|---|---|
pessimistic | Wait for successful mutation, then redirect. |
optimistic | Schedule redirect without waiting for the server response. |
undoable | Schedule 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>
<script lang="ts">
import { useEdit } from '@ginjou/svelte'
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.
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>
<script lang="ts">
import { useEdit } from '@ginjou/svelte'
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>
<script lang="ts">
import { useEdit } from '@ginjou/svelte'
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>
<script lang="ts">
import { useEdit } from '@ginjou/svelte'
const editPage = useEdit({
resource: 'posts',
id: 1,
})
let title = $state('')
let status = $state('draft')
$effect(() => {
if (!editPage.record)
return
title = editPage.record.title ?? ''
status = editPage.record.status ?? 'draft'
})
async function submit(e: SubmitEvent) {
e.preventDefault()
await editPage.save({ title, status })
}
</script>
{#if editPage.record}
<form onsubmit={submit}>
<input bind:value={title}>
<select bind:value={status}>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
<button disabled={editPage.isLoading}>
{editPage.isLoading ? 'Saving...' : 'Update'}
</button>
</form>
{/if}
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.
| Extra | Meaning |
|---|---|
id | The 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>
<script lang="ts">
import { useShow } from '@ginjou/svelte'
const showPage = useShow({
resource: 'posts',
id: 1,
})
</script>
{#if showPage.isLoading}
Loading...
{:else if !showPage.record}
Not found.
{:else}
<article>
<h1>{showPage.record.title}</h1>
<p>{showPage.record.status}</p>
</article>
{/if}
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>
<script lang="ts">
import { useShow } from '@ginjou/svelte'
const showPage = useShow()
</script>
Manual example:
<script setup lang="ts">
import { useShow } from '@ginjou/vue'
const showPage = useShow({
resource: 'posts',
id: 42,
})
</script>
<script lang="ts">
import { useShow } from '@ginjou/svelte'
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.
| Extra | Meaning |
|---|---|
options | Merged option list for the field. |
search | Current search text. |
currentPage | Current page ref. |
perPage | Page 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>
<script lang="ts">
import { useSelect } from '@ginjou/svelte'
const authorSelect = useSelect({
resource: 'authors',
labelKey: 'profile.name',
valueKey: 'uuid',
})
</script>
Search
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>
<script lang="ts">
import { FilterOperator } from '@ginjou/core'
import { useSelect } from '@ginjou/svelte'
const categorySelect = useSelect({
resource: 'categories',
labelKey: 'name',
searchToFilters(value) {
if (!value)
return []
return [
{
field: 'name',
operator: FilterOperator.contains,
value,
},
]
},
})
</script>
<input bind:value={categorySelect.search}>
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>
<script lang="ts">
import { useSelect } from '@ginjou/svelte'
const categorySelect = useSelect({
resource: 'categories',
labelKey: 'name',
pagination: {
current: 1,
perPage: 10,
},
})
function nextPage() {
categorySelect.currentPage = (categorySelect.currentPage ?? 1) + 1
}
function prevPage() {
categorySelect.currentPage = Math.max((categorySelect.currentPage ?? 1) - 1, 1)
}
</script>
<select>
{#each categorySelect.options ?? [] as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<button onclick={prevPage}>Prev</button>
<button onclick={nextPage}>Next</button>
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>
<script lang="ts">
import { useSelect } from '@ginjou/svelte'
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>
<script lang="ts">
import { useDeleteOne } from '@ginjou/svelte'
const deletePost = useDeleteOne({
resource: 'posts',
id: 1,
mutationMode: 'undoable',
})
async function remove() {
await deletePost.mutateAsync()
}
</script>