Guides

Form

Build forms and handle CRUD operations with Controllers

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.

Controllers are built from modular Data Composables like useCreateOne, useUpdateOne, and useGetOne. You can always customize them or use lower-level composables for specialized needs.

CRUD Operations

This section introduces Controllers for standard data operations: creating, updating, fetching, and deleting records.

Create

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 record
  • useCreate: Complete page Controller that combines useResource (context), useCreateOne (mutation), and useNavigateTo (navigation)

Composition:

  • Data Composable: Uses useCreateOne to execute the creation logic
  • Controller Action: The save method triggers the mutation
  • Mutation On Success: useCreateOne automatically invalidates related caches (list and many queries) so that list pages display the new record
  • Controller On Success: save waits for the mutation to complete, then navigates the user to the list page

Mutation Modes:

The save function accepts an options object with a mode parameter that controls when navigation happens:

  • Pessimistic (default): Waits for the server to confirm the record was created before navigating. This is safe but may feel slower.
  • Optimistic: Navigates immediately and sends the request in the background. The UI updates instantly even if the server request is still pending.
  • Undoable: Navigates immediately but displays a toast notification with an "undo" button. If the user clicks undo within the window, the creation is cancelled.
mermaid
graph TD
    A[useCreate] --> B[useResource]
    B -- Resource & Fetcher Name --> C[useCreateOne]
    A --> C
    A --> D[useNavigateTo]
    subgraph Data Management
        C --> |Invalidates| E["list, many"]
    end
    subgraph Actions
        F[save] --> |Calls| C
        C --> |On Success| D
    end
    A --> F
<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>

Edit

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 record
  • useUpdateOne: Updates existing data
  • useEdit: 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 navigating

Composition:

  • Data Composables: Uses useGetOne to fetch data and useUpdateOne to update it
  • Loading State: Exposes isLoading that reflects both the fetch and mutation states
  • Controller Action: The save method triggers the update mutation
  • Mutation On Success: useUpdateOne automatically invalidates affected caches (list, many, and one queries) so that the updated record displays everywhere
  • Controller On Success: save waits for the mutation to complete, then navigates the user to the list page

Mutation Modes:

Like useCreate, the save function supports different modes for when navigation occurs:

  • Pessimistic (default): Waits for the server to confirm the update before navigating
  • Optimistic: Navigates immediately while the update request runs in the background
  • Undoable: Navigates immediately but displays an undo button in case the update fails

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 })
mermaid
graph TD
    A[useEdit] --> B[useResource]
    B -- Resource & Fetcher Name --> C[useGetOne]
    B -- Resource & Fetcher Name --> D[useUpdateOne]
    A --> C
    A --> D
    A --> E[useNavigateTo]

    subgraph Data Management
        C --> |Fetches| F[record]
        D --> |Invalidates| G["list, many, one"]
    end

    subgraph Actions
        H[save] --> |Calls| D
        D --> |On Success| E
    end

    A --> F
    A --> H
<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>

Show

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 record

When 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:

vue
<script setup lang="ts">
const { record } = useShow({
    resource: 'posts',
    id: '123' // Optional: explicitly set the ID
})
</script>
mermaid
graph TD
    A[useShow] --> B[useResource]
    B -- Resource & Fetcher Name --> D[useGetOne]
    A --> C[ID Resolution]
    C -- Read from URL/Props --> D

    subgraph Data Flow
        D --> |Fetches| E[record]
    end

    A --> E
<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>

Delete

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:

  • Data Composable: Executes the delete API request
  • Mutation On Success: Displays a success notification and invalidates the list and many queries so the deleted record no longer appears
  • Mutation On Error: Displays an error notification with error details

Confirmation 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().

mermaid
graph TD
    A[User Action] --> B{Confirmation Modal}
    B -- Confirmed --> C[useDeleteOne]
    B -- Cancelled --> D[Dismiss]
    C --> E[Invalidate list, many]
    E --> F[Show Success Toast]
    C --> G{Error?}
    G -- Yes --> H[Show Error Toast]
<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>

Form Fields

This section covers specialized Composables for specific form field patterns, such as managing related data in select inputs.

Select

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:

  1. useGetList: Fetches available options for the dropdown (supports search, filtering, and pagination)
  2. useGetMany: Fetches the specific data for the currently selected value(s), ensuring the label displays correctly

useSelect 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:

  • Your backend uses different field names for search
  • You need multiple filters to represent a single search input
  • You want to apply special operators (like icontains, startsWith) instead of the default contains
  • Your backend requires a custom filter format or syntax
  • You need to handle empty search values differently
mermaid
graph TD
    A[useSelect] --> B[useResource]
    B -- Resource & Fetcher Name --> C[useGetList]
    B -- Resource & Fetcher Name --> D[useGetMany]
    A --> C
    A --> D

    subgraph User Input
        E[Search / Pagination] --> C
        F[Value Prop] --> D
    end

    subgraph Composition
        C --> |ListData| G["Merge & Deduplicate<br/>unionBy"]
        D --> |ValueData| G
    end

    G --> H[options]
    A --> H
    A --> E

Understanding 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>