Ginjou provides high-level Controllers that simplify building forms and handling CRUD operations. Controllers manage complex page logic automatically: fetching data, tracking loading states, handling mutations, and navigating after success.
The main difference between Controllers and lower-level Data Composables is automation. Controllers orchestrate multiple composables and inject context automatically, letting you focus on the UI.
useCreateOne, useUpdateOne, and useGetOne. You can always customize them or use lower-level composables for specialized needs.This section introduces Controllers for standard data operations: creating, updating, fetching, and deleting records.
useCreate is a Controller for create pages. It handles data creation and manages the entire page flow, including resource identification and navigation.
Compared to Lower-Level Composables:
useCreateOne: Handles only the API request to create a recorduseCreate: Complete page Controller that combines useResource (context), useCreateOne (mutation), and useNavigateTo (navigation)Composition:
useCreateOne to execute the creation logicsave method triggers the mutationuseCreateOne automatically invalidates related caches (list and many queries) so that list pages display the new recordsave waits for the mutation to complete, then navigates the user to the list pageMutation Modes:
The save function accepts an options object with a mode parameter that controls when navigation happens:
<script setup lang="ts">
import { useCreate } from '@ginjou/vue'
import { reactive } from 'vue'
const { save, isLoading } = useCreate({
resource: 'posts',
})
const formData = reactive({
title: '',
status: 'draft',
})
async function handleSubmit() {
// Use pessimistic mode (waits for server before navigating)
await save(formData, { mode: 'pessimistic' })
// Or optimistic mode for instant navigation:
// await save(formData, { mode: 'optimistic' })
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="formData.title" placeholder="Post title">
<select v-model="formData.status">
<option value="draft">
Draft
</option>
<option value="published">
Published
</option>
</select>
<button type="submit" :disabled="isLoading">
{{ isLoading ? 'Creating...' : 'Create Post' }}
</button>
</form>
</template>
<!-- WIP -->
<script>
// ...
</script>
useEdit is a Controller for edit pages. It handles fetching existing data and updating it, while managing resource identification and navigation.
Compared to Lower-Level Composables:
useGetOne: Fetches a single recorduseUpdateOne: Updates existing datauseEdit: Combines useGetOne and useUpdateOne to provide complete page functionality. It automatically fetches data when the page opens and waits for the update mutation to complete before navigatingComposition:
useGetOne to fetch data and useUpdateOne to update itisLoading that reflects both the fetch and mutation statessave method triggers the update mutationuseUpdateOne automatically invalidates affected caches (list, many, and one queries) so that the updated record displays everywheresave waits for the mutation to complete, then navigates the user to the list pageMutation Modes:
Like useCreate, the save function supports different modes for when navigation occurs:
Form Synchronization:
When the record data loads, you typically copy it into reactive form state. Use a watch to sync:
watch(record, (newValue) => {
Object.assign(formData, newValue)
}, { immediate: true })
<script setup lang="ts">
import { useEdit } from '@ginjou/vue'
import { reactive, watch } from 'vue'
const { record, save, isLoading } = useEdit({
resource: 'posts',
id: '123'
})
const formData = reactive({
title: '',
content: '',
status: 'draft',
})
// Sync form data when record loads
watch(record, (newRecord) => {
if (newRecord) {
Object.assign(formData, newRecord)
}
}, { immediate: true })
async function handleSubmit() {
await save(formData, { mode: 'pessimistic' })
}
</script>
<template>
<form v-if="record" @submit.prevent="handleSubmit">
<input v-model="formData.title" placeholder="Post title">
<textarea v-model="formData.content" placeholder="Post content" />
<select v-model="formData.status">
<option value="draft">
Draft
</option>
<option value="published">
Published
</option>
</select>
<button type="submit" :disabled="isLoading">
{{ isLoading ? 'Saving...' : 'Save Changes' }}
</button>
</form>
<div v-else>
Loading...
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
useShow is a Controller for detail view pages. It automatically resolves the record ID from your routing context and fetches the record details.
Compared to Lower-Level Composables:
useGetOne: Fetches data for a given ID (requires you to manage the ID)useShow: Automatically resolves the record ID from URL parameters or route props, then combines it with the resource context to fetch the correct recordWhen to Use:
Use useShow when displaying a single record on a dedicated detail page. It handles the common pattern of reading the ID from the URL and fetching the corresponding record.
Use useGetOne directly when you need more control over which record to fetch or when the ID comes from a different source than route parameters.
ID Resolution:
useShow automatically reads the ID from your routing context. In most frameworks, this is the URL parameter (e.g., /posts/123 extracts 123). If the automatic resolution doesn't match your routing structure, you can pass the ID explicitly:
<script setup lang="ts">
const { record } = useShow({
resource: 'posts',
id: '123' // Optional: explicitly set the ID
})
</script>
<script setup lang="ts">
import { useShow } from '@ginjou/vue'
// Automatically reads ID from URL (e.g., /posts/123)
const { record, isLoading } = useShow({
resource: 'posts'
// ID is read from route parameters automatically
})
</script>
<template>
<div v-if="record" class="post-detail">
<h1>{{ record.title }}</h1>
<p>{{ record.content }}</p>
<span class="status" :class="`status-${record.status}`">
{{ record.status }}
</span>
</div>
<div v-else-if="isLoading">
Loading...
</div>
<div v-else>
Record not found
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
useDeleteOne is a mutation composable for deleting records. It integrates with your notification system and cache management to provide a complete deletion experience.
useDeleteOne performs a destructive, permanent action. Always implement proper user confirmation (such as a modal dialog) before triggering this mutation. Users expect to confirm destructive actions.Composition:
list and many queries so the deleted record no longer appearsConfirmation Pattern:
Always show a confirmation modal before calling the delete mutation. This pattern protects users from accidental data loss:
function handleDeleteClick(id: string) {
if (confirm('Are you sure you want to delete this post? This action cannot be undone.')) {
deleteOne({ resource: 'posts', id })
}
}
For a better UX, use a modal dialog component instead of the browser's native confirm().
<script setup lang="ts">
import { useDeleteOne } from '@ginjou/vue'
import { ref } from 'vue'
const { mutate: deleteOne, isLoading } = useDeleteOne()
const showConfirmModal = ref(false)
const pendingDeleteId = ref<string | null>(null)
function openDeleteConfirm(id: string) {
pendingDeleteId.value = id
showConfirmModal.value = true
}
function confirmDelete() {
if (pendingDeleteId.value) {
deleteOne({
resource: 'posts',
id: pendingDeleteId.value,
})
showConfirmModal.value = false
pendingDeleteId.value = null
}
}
function cancelDelete() {
showConfirmModal.value = false
pendingDeleteId.value = null
}
</script>
<template>
<div>
<!-- Your content here -->
<button class="btn-danger" @click="openDeleteConfirm('123')">
Delete
</button>
<!-- Confirmation Modal -->
<div v-if="showConfirmModal" class="modal-overlay">
<div class="modal">
<h2>Delete Post?</h2>
<p>This action cannot be undone.</p>
<div class="modal-actions">
<button :disabled="isLoading" class="btn-danger" @click="confirmDelete">
{{ isLoading ? 'Deleting...' : 'Delete' }}
</button>
<button :disabled="isLoading" @click="cancelDelete">
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
This section covers specialized Composables for specific form field patterns, such as managing related data in select inputs.
useSelect is a composable for managing select input options. It solves the common challenge of displaying selected values that may not exist on the current dropdown page.
The Problem:
When a select dropdown has many options and uses pagination, the currently selected value might not appear on the current page. For example, if a user selects "Category #500" but the dropdown only shows categories 1-50 on the first page, the label won't display correctly until the user navigates to the right page.
The Solution:
useSelect makes two coordinated requests:
useGetList: Fetches available options for the dropdown (supports search, filtering, and pagination)useGetMany: Fetches the specific data for the currently selected value(s), ensuring the label displays correctlyuseSelect merges these results into a single options array for your UI.
Pagination & Search:
The composable exposes currentPage, perPage, and search properties that you control:
const { options, search, currentPage, perPage } = useSelect({
resource: 'categories',
value, // Reactive ref with selected ID(s)
})
// User types in search box
search.value = 'electronics'
// User clicks "next page"
currentPage.value = 2
// User changes items per page
perPage.value = 25
Custom Search Function:
The search input is always a string | undefined value. By default, useSelect converts the search value into a simple filter using the resource's label field:
// Default behavior: search text is wrapped in a simple filter
// search: 'electronics' → [{ field: labelKey, operator: 'contains', value: 'electronics' }]
// search: undefined → [] (no filters)
If your backend requires a different filter structure or serialization for the search value, use searchToFilters to customize how the search string is converted into Filters:
Example 1: Custom field and operator
const { options } = useSelect({
resource: 'products',
value,
searchToFilters: (searchValue) => {
// Convert search to custom filter format
if (!searchValue)
return []
return [{
field: 'keyword',
operator: FilterOperator.eq,
value: searchValue,
}]
},
})
Example 2: Multiple filters from single search value
const { options } = useSelect({
resource: 'products',
value,
searchToFilters: (searchValue) => {
// Apply the search across multiple fields
if (!searchValue)
return []
return [
{ field: 'name', operator: 'contains', value: searchValue },
{ field: 'description', operator: 'contains', value: searchValue },
]
},
})
Example 3: Case-insensitive search
const { options } = useSelect({
resource: 'products',
value,
searchToFilters: (searchValue) => {
// Use case-insensitive operator if supported by your backend
if (!searchValue)
return []
return [{
field: 'name',
operator: 'icontains', // Case-insensitive contains
value: searchValue,
}]
},
})
When to use searchToFilters:
icontains, startsWith) instead of the default containsUnderstanding the options Array:
The options array returned by useSelect contains merged results from both useGetList and useGetMany. Each option has a consistent structure:
interface SelectOption {
label: string // Display text (typically from resource's label field)
value: any // Value to assign to form model
data: any // Full record data from backend
isSelected?: boolean // True if this is the current selected value
}
Example Result:
If you have a category with id: 5, name: "Electronics" selected, and the dropdown is on page 1 showing categories 1-50:
const options = [
// From useGetMany (selected value - always included)
{ label: 'Electronics', value: 5, data: { id: 5, name: 'Electronics' }, isSelected: true },
// From useGetList (current page results)
{ label: 'Books', value: 1, data: { id: 1, name: 'Books' } },
{ label: 'Clothing', value: 2, data: { id: 2, name: 'Clothing' } },
// ... more options up to 50
]
The array is automatically deduplicated, so if "Electronics" appears in both results, it only shows once with isSelected: true.
<script setup lang="ts">
import { useSelect } from '@ginjou/vue'
import { computed, ref } from 'vue'
const selectedCategoryId = ref()
const {
options,
search,
currentPage,
perPage,
} = useSelect({
resource: 'categories',
value: selectedCategoryId,
})
// For backends that need custom search syntax
const {
options: productOptions,
search: productSearch,
} = useSelect({
resource: 'products',
value: ref(),
searchToFilters: (searchValue) => {
// Serialize search value to custom filter format
// For example, if searching by multiple fields or special operators
return [
{ field: 'name', operator: 'contains', value: searchValue },
{ field: 'sku', operator: 'contains', value: searchValue },
]
},
})
</script>
<template>
<div class="form-group">
<!-- Simple Select -->
<label>Category</label>
<select v-model="selectedCategoryId">
<option :value="undefined">
-- Select a category --
</option>
<option v-for="opt in options" :key="opt.data.id" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- Select with Search -->
<label>Product (with search)</label>
<input
v-model="productSearch"
placeholder="Search products..."
class="search-input"
>
<select>
<option :value="undefined">
-- Select a product --
</option>
<option v-for="opt in productOptions" :key="opt.data.id" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- Pagination Controls -->
<div class="pagination">
<button :disabled="currentPage === 1" @click="currentPage--">
Previous
</button>
<span>Page {{ currentPage }} - {{ perPage }} per page</span>
<button @click="currentPage++">
Next
</button>
</div>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>