Guides

Data

Explain fetcher contracts, query and mutation composables, and shared data behavior.

The data layer is where Ginjou reads and writes data.

This page focuses on fetchers, low-level composables, and the shared behaviors that sit underneath higher-level controllers.

mermaid
flowchart LR
	A[Data Composable] --> B[TanStack Query]
	B --> C[Fetcher method]
	C --> D[Backend]

Fetcher Context

Fetcher context is where the app registers one or more fetchers.

Those fetchers follow the shared fetcher contract, and every query or mutation composable eventually calls one fetcher method.

Interface

interface Fetcher<TRecord = any> {
    getList?: (props: {
        resource: string
        pagination?: { current: any, perPage: number }
        sorters?: Array<{ field: string, order: 'asc' | 'desc' }>
        filters?: unknown[]
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord[], total: number }>

    getOne?: (props: {
        resource: string
        id: string | number
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord }>

    getMany?: (props: {
        resource: string
        ids: Array<string | number>
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord[] }>

    createOne?: (props: {
        resource: string
        params: Record<string, any>
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord }>

    createMany?: (props: {
        resource: string
        params: Array<Record<string, any>>
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord[] }>

    updateOne?: (props: {
        resource: string
        id: string | number
        params?: Record<string, any>
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord }>

    updateMany?: (props: {
        resource: string
        ids: Array<string | number>
        params?: Record<string, any>
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord[] }>

    deleteOne?: (props: {
        resource: string
        id: string | number
        params?: Record<string, any>
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord }>

    deleteMany?: (props: {
        resource: string
        ids: Array<string | number>
        params?: Record<string, any>
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord[] }>

    custom?: (props: {
        url: string
        method: 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options'
        query?: Record<string, any>
        payload?: Record<string, any>
        headers?: Record<string, any>
        meta?: Record<string, any>
    }) => Promise<{ data: TRecord }>
}

Methods

MethodExplanationComposables
getListRead a collectionuseGetList, useGetInfiniteList
getOneRead one recorduseGetOne
getManyRead many records by idsuseGetMany
createOneCreate one recorduseCreateOne
createManyCreate many recordsuseCreateMany
updateOneUpdate one recorduseUpdateOne
updateManyUpdate many recordsuseUpdateMany
deleteOneDelete one recorduseDeleteOne
deleteManyDelete many recordsuseDeleteMany
customHandle non-CRUD read or writeuseCustom, useCustomMutation

Adapter support varies. Some backend packages implement only a subset of these methods.

You do not need to implement every method on day one.

Start with the methods your app really uses, then add more as pages need them.

import { defineFetchersContext } from '@ginjou/vue'

defineFetchersContext({
    default: {
        async getList({ resource }) {
            const response = await fetch(`/api/${resource}`)
            const data = await response.json()

            return {
                data,
                total: data.length,
            }
        },
        async getOne({ resource, id }) {
            const response = await fetch(`/api/${resource}/${id}`)

            return {
                data: await response.json(),
            }
        },
        async createOne({ resource, params }) {
            const response = await fetch(`/api/${resource}`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(params),
            })

            return {
                data: await response.json(),
            }
        },
        async updateOne({ resource, id, params }) {
            const response = await fetch(`/api/${resource}/${id}`, {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(params),
            })

            return {
                data: await response.json(),
            }
        },
        async deleteOne({ resource, id }) {
            const response = await fetch(`/api/${resource}/${id}`, {
                method: 'DELETE',
            })

            return {
                data: await response.json(),
            }
        },
    },
})

Resource

resource names the record set.

fetcherName picks the data source.

Most calls only need resource. Add fetcherName when more than one backend is registered and a request should target a specific one.

import { useGetList, useGetOne, useUpdateOne } from '@ginjou/vue'

const postsList = useGetList({
    resource: 'posts',
})

const postDetail = useGetOne({
    resource: 'posts',
    id: 1,
})

const updatePost = useUpdateOne({
    resource: 'posts',
    id: 1,
})

Get List

useGetList reads a collection.

It returns a TanStack useQuery result.

ExtraMeaning
recordsShortcut for the list records.
<script setup lang="ts">
import { useGetList } from '@ginjou/vue'
import { computed } from 'vue'

const postsQuery = useGetList({
    resource: 'posts',
    pagination: {
        current: 1,
        perPage: 20,
    },
    sorters: [
        { field: 'createdAt', order: 'desc' },
    ],
    filters: [
        { field: 'status', operator: 'eq', value: 'published' },
    ],
})

const posts = computed(() => postsQuery.records.value ?? [])
const total = computed(() => postsQuery.data.value?.total ?? 0)
</script>

useGetInfiniteList also uses fetcher.getList. The difference is the TanStack Query mode.

It returns a TanStack useInfiniteQuery result.

ExtraMeaning
recordsLoaded records grouped by page.
<script setup lang="ts">
import { useGetInfiniteList } from '@ginjou/vue'
import { computed } from 'vue'

const feedQuery = useGetInfiniteList({
    resource: 'posts',
    pagination: {
        current: 1,
        perPage: 20,
    },
})

const pages = computed(() => feedQuery.records.value ?? [])
const flatPosts = computed(() => pages.value.flat())
</script>

filters

Filters are a tree.

Use one rule for simple cases. Use and or or when the backend query needs grouped conditions.

Conditional Operator:

OperatorExplanation
andAll child filters must match.
orAny child filter can match.

Logical Operator

OperatorExplanation
eqEqual to the value.
neNot equal to the value.
ltLess than the value.
gtGreater than the value.
lteLess than or equal to the value.
gteGreater than or equal to the value.
inMatch a value in a list.
ninExclude a value in a list.
containsText contains the value.
ncontainsText does not contain the value.
containssCase-sensitive contains.
ncontainssCase-sensitive not contains.
startswithText starts with the value.
nstartswithText does not start with the value.
startswithsCase-sensitive starts with.
nstartswithsCase-sensitive not starts with.
endswithText ends with the value.
nendswithText does not end with the value.
endswithsCase-sensitive ends with.
nendswithsCase-sensitive not ends with.
betweenMatch a range.
nbetweenExclude a range.
nullValue is null.
nnullValue is not null.
import { useGetList } from '@ginjou/vue'

const postsQuery = useGetList({
    resource: 'posts',
    filters: [
        { field: 'status', operator: 'eq', value: 'published' },
        {
            operator: 'or',
            value: [
                { field: 'title', operator: 'contains', value: keyword.value },
                { field: 'summary', operator: 'contains', value: keyword.value },
            ],
        },
    ],
})

sorters

Sorters are an ordered array.

The first sorter is the primary sort. The rest act as tie-breakers.

import { useGetList } from '@ginjou/vue'

const postsQuery = useGetList({
    resource: 'posts',
    sorters: [
        { field: 'createdAt', order: 'desc' },
        { field: 'id', order: 'desc' },
    ],
})

pagination

Pagination always uses current and perPage.

current can be a page number or another page token, depending on what the fetcher expects.

Page number example:

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

const postsQuery = useGetList({
    resource: 'posts',
    pagination: {
        current: 3,
        perPage: 20,
    },
})
</script>

Cursor example:

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

const feedQuery = useGetInfiniteList({
    resource: 'posts',
    pagination: {
        current: 'cursor:0',
        perPage: 20,
    },
})
</script>

Get One

useGetOne reads one record.

It returns a TanStack useQuery result.

ExtraMeaning
recordShortcut for the single record.
<script setup lang="ts">
import { useGetOne } from '@ginjou/vue'

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

<template>
    <div v-if="postQuery.isLoading">
        Loading...
    </div>
    <div v-else-if="postQuery.isError">
        Failed to load.
    </div>
    <article v-else-if="postQuery.record">
        <h1>{{ postQuery.record.title }}</h1>
    </article>
</template>

Get Many

useGetMany reads many records from known ids.

If fetcher.getMany is missing, Ginjou falls back to repeated fetcher.getOne.

It returns a TanStack useQuery result.

ExtraMeaning
recordsShortcut for the returned records.
import { useGetMany, useGetOne } from '@ginjou/vue'
import { computed } from 'vue'

const postQuery = useGetOne({
    resource: 'posts',
    id: 1,
})

const commentsQuery = useGetMany({
    resource: 'comments',
    ids: computed(() => postQuery.record.value?.commentIds ?? []),
})

const comments = computed(() => commentsQuery.records.value ?? [])

Custom Query

useCustom is for read endpoints that do not fit CRUD.

Use it for reports, stats, or search endpoints.

It returns a TanStack useQuery result.

ExtraMeaning
recordShortcut for the returned data record.
import { useCustom } from '@ginjou/vue'
import { computed } from 'vue'

const reportQuery = useCustom({
    url: '/reports/monthly',
    method: 'get',
    query: {
        month: selectedMonth.value,
    },
})

const report = computed(() => reportQuery.record.value)

Query Options

queryOptions comes from TanStack Query.

Use it for enabled, callbacks, placeholderData, retry, and other TanStack Query settings.

import { useGetOne } from '@ginjou/vue'
import { computed } from 'vue'

const postId = computed(() => route.params.id)

const postQuery = useGetOne({
    resource: 'posts',
    id: postId,
    queryOptions: {
        enabled: computed(() => postId.value != null && postId.value !== ''),
        onSuccess: () => {
            isDrawerOpen.value = true
        },
    },
})

Create One

useCreateOne creates one record.

It returns TanStack useMutation result.

ExtraMeaning
mutateSync-style trigger with Ginjou mutation props.
mutateAsyncPromise-style trigger with Ginjou mutation props.
import { useCreateOne } from '@ginjou/vue'

const createPost = useCreateOne({
    resource: 'posts',
})

await createPost.mutateAsync({
    params: {
        title: 'Hello Ginjou',
        status: 'draft',
    },
})

Create Many

useCreateMany creates many records.

If fetcher.createMany is missing, Ginjou falls back to repeated fetcher.createOne.

It returns TanStack useMutation result.

ExtraMeaning
mutateSync-style trigger with Ginjou mutation props.
mutateAsyncPromise-style trigger with Ginjou mutation props.
import { useCreateMany } from '@ginjou/vue'

const createPosts = useCreateMany({
    resource: 'posts',
})

await createPosts.mutateAsync({
    params: csvRows.value.map(row => ({
        title: row.title,
        status: 'draft',
    })),
})

Mutation Mode

mutationMode controls when update and delete feel committed.

Choose it based on the UX promise of the page, not just on backend speed.

ModeMeaningComposables
pessimisticwait for the serveruseUpdateOne, useUpdateMany, useDeleteOne, useDeleteMany
optimisticupdate cache firstuseUpdateOne, useUpdateMany, useDeleteOne, useDeleteMany
undoablewait for a timeout before the real requestuseUpdateOne, useUpdateMany, useDeleteOne, useDeleteMany

pessimistic

Use this when the UI should only change after the server confirms success.

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

const updatePost = useUpdateOne({
    resource: 'posts',
    id: 1,
    mutationMode: 'pessimistic',
})
</script>

optimistic

Use this when the UI should feel immediate and you can tolerate rollback if the request fails.

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

const updatePost = useUpdateOne({
    resource: 'posts',
    id: 1,
    mutationMode: 'optimistic',
})
</script>

undoable

The default undoable timeout is 5000 ms.

Use this when users should get a short window to cancel destructive actions.

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

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

Update One

useUpdateOne updates one record.

It returns TanStack useMutation result.

ExtraMeaning
mutateSync-style trigger with Ginjou mutation props.
mutateAsyncPromise-style trigger with Ginjou mutation props.
import { useUpdateOne } from '@ginjou/vue'

const publishPost = useUpdateOne({
    resource: 'posts',
    id: 1,
    mutationMode: 'optimistic',
})

await publishPost.mutateAsync({
    params: {
        status: 'published',
    },
})

Update Many

useUpdateMany updates many records.

If fetcher.updateMany is missing, Ginjou falls back to repeated fetcher.updateOne.

It returns TanStack useMutation result.

ExtraMeaning
mutateSync-style trigger with Ginjou mutation props.
mutateAsyncPromise-style trigger with Ginjou mutation props.
import { useUpdateMany } from '@ginjou/vue'

const publishPosts = useUpdateMany({
    resource: 'posts',
    mutationMode: 'optimistic',
})

await publishPosts.mutateAsync({
    ids: selectedIds.value,
    params: {
        status: 'published',
    },
})

Delete One

useDeleteOne deletes one record.

It returns TanStack useMutation result.

ExtraMeaning
mutateSync-style trigger with Ginjou mutation props.
mutateAsyncPromise-style trigger with Ginjou mutation props.
import { useDeleteOne } from '@ginjou/vue'

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

deletePost.mutate()

Delete Many

useDeleteMany deletes many records.

If fetcher.deleteMany is missing, Ginjou falls back to repeated fetcher.deleteOne.

It returns TanStack useMutation result.

ExtraMeaning
mutateSync-style trigger with Ginjou mutation props.
mutateAsyncPromise-style trigger with Ginjou mutation props.
import { useDeleteMany } from '@ginjou/vue'

const deletePosts = useDeleteMany({
    resource: 'posts',
    mutationMode: 'undoable',
})

await deletePosts.mutateAsync({
    ids: selectedIds.value,
})

Custom Mutation

useCustomMutation is for write actions that do not fit CRUD.

It does not bring CRUD invalidation presets or mutationMode.

It returns TanStack useMutation result.

ExtraMeaning
mutateSync-style trigger with Ginjou mutation props.
mutateAsyncPromise-style trigger with Ginjou mutation props.
import { useCustomMutation } from '@ginjou/vue'

const publishPost = useCustomMutation({
    url: '/posts/1/publish',
    method: 'post',
})

await publishPost.mutateAsync()

Mutation Options

mutationOptions also comes from TanStack Query.

Use it for callbacks and lifecycle control. For details, read the TanStack Query.

import { useUpdateOne } from '@ginjou/vue'

const updatePost = useUpdateOne({
    resource: 'posts',
    id: 1,
    mutationOptions: {
        onSuccess: () => {
            isEditorOpen.value = false
        },
    },
})

Multiple Fetchers

You can register more than one fetcher.

Use fetcherName to choose the source. If you omit it, Ginjou uses default.

import { defineFetchersContext, useGetList } from '@ginjou/vue'

defineFetchersContext({
    default: restFetcher,
    legacy: legacyFetcher,
})

const currentPosts = useGetList({
    resource: 'posts',
})

const legacyPosts = useGetList({
    resource: 'posts',
    fetcherName: 'legacy',
})

Auth Error Handling

Built-in query and mutation composables already call auth.checkError.

That means most pages do not need to wire auth error handling by hand unless they are making custom requests outside the standard hooks.

Cache Invalidation

invalidates controls which cached queries should refresh after a successful mutation.

Targets are all, resource, list, many, and one.

Default invalidates:

ComposableDefault invalidates
useCreateOnelist, many
useCreateManylist, many
useUpdateOnelist, many, one
useUpdateManylist, many, one
useDeleteOnelist, many
useDeleteManylist, many
import { useUpdateOne } from '@ginjou/vue'

const updatePost = useUpdateOne({
    resource: 'posts',
    id: 1,
    invalidates: ['one', 'list'],
})

Notifications

Query and mutation composables can show default success and error messages.

Use successNotify or errorNotify to replace them. Use false to disable them.

Realtime

When realtime is enabled, query composables subscribe and standard mutation composables publish.

The default channel is resources/{resource}. useCustom and useCustomMutation can use their own channel or event.

Meta

meta is the backend-specific extension point.

Ginjou forwards it to the fetcher and includes it in query keys. Use it for backend details that do not belong in the shared contract. Keep it small, and read the backend guides for the exact shape.

Copyright © 2026