Data is the foundation of your application. Ginjou provides composables to fetch, update, and synchronize data from your backend with minimal boilerplate. These composables handle caching, state management, and real-time synchronization automatically using TanStack Query.
A Fetcher provider acts as a translation layer between your application and any backend API. By implementing a standard Fetcher interface, you enable Ginjou to work with REST APIs, GraphQL, Supabase, Directus, or any other backend system without changing your application code.
The Fetcher interface defines these methods:
interface Fetcher {
getList: (props: GetListProps) => Promise<GetListResult>
getMany: (props: GetManyProps) => Promise<GetManyResult>
getOne: (props: GetOneProps) => Promise<GetOneResult>
createOne: (props: CreateOneProps) => Promise<CreateResult>
createMany: (props: CreateManyProps) => Promise<CreateManyResult>
updateOne: (props: UpdateOneProps) => Promise<UpdateResult>
updateMany: (props: UpdateManyProps) => Promise<UpdateManyResult>
deleteOne: (props: DeleteOneProps) => Promise<DeleteOneResult>
deleteMany: (props: DeleteManyProps) => Promise<DeleteManyResult>
custom: (props: CustomProps) => Promise<CustomResult>
}
Once you configure a Fetcher, use Data Composables like useGetOne and useGetList to work with your data. These composables handle the details like specifying which resource and ID to fetch, freeing you to focus on your application logic.
Data composables follow a consistent pattern:
Ginjou provides composables for Create, Read, Update, and Delete (CRUD) operations. Each operation supports working with single or multiple records.
Use useCreateOne to create a single record. This composable manages mutation state, cache invalidation, and notifications automatically.
<script setup lang="ts">
import { useCreateOne } from '@ginjou/vue'
const { mutate: create, isPending, isError } = useCreateOne({
resource: 'products',
})
async function addProduct(formData: any) {
try {
await create({ params: formData })
}
catch (error) {
console.error('Create failed:', error)
}
}
</script>
<template>
<button
:disabled="isPending"
@click="addProduct({ name: 'New Product', price: 999 })"
>
{{ isPending ? 'Creating...' : 'Create Product' }}
</button>
<div v-if="isError" class="error">
Failed to create product
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useCreateOne composable:
resource as the required parameter during initializationparams when calling mutate() or mutateAsync()Use useCreateMany to create multiple records in a single operation. This is useful for bulk imports or batch creation.
<script setup lang="ts">
import { useCreateMany } from '@ginjou/vue'
const { mutate: createBatch, isPending } = useCreateMany({
resource: 'products',
})
async function importProducts(items: any[]) {
await createBatch({ params: items })
}
</script>
<template>
<button
:disabled="isPending"
@click="importProducts([
{ name: 'Product 1', price: 100 },
{ name: 'Product 2', price: 200 },
])"
>
{{ isPending ? 'Importing...' : 'Import Products' }}
</button>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useCreateMany composable:
resource as the required parameterparams when calling mutate() or mutateAsync()Use useGetOne to fetch a single record by ID. The composable creates a unique query key based on the resource and ID, caches the result, and automatically reuses cached data across your application.
<script setup lang="ts">
import { useGetOne } from '@ginjou/vue'
const { record, isFetching, isError, error } = useGetOne({
resource: 'products',
id: '123',
})
</script>
<template>
<div v-if="isFetching">
Loading...
</div>
<div v-else-if="isError">
Error: {{ error?.message }}
</div>
<div v-else>
<h1>{{ record?.name }}</h1>
<p>Price: ${{ record?.price }}</p>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useGetOne composable:
resource and id as required parametersrecord ref containing the fetched dataisFetching) and error states automaticallyid is provided and non-emptyUse useGetList to fetch multiple records with pagination. This composable is best for displaying data in tables, lists, or other paginated views.
<script setup lang="ts">
import { useGetList } from '@ginjou/vue'
const { records, isFetching, total } = useGetList({
resource: 'products',
})
</script>
<template>
<div v-if="isFetching">
Loading products...
</div>
<div v-else>
<p>Total: {{ total }} products</p>
<ul>
<li v-for="item in records" :key="item.id">
{{ item.name }} - ${{ item.price }}
</li>
</ul>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useGetList composable:
resource name as the required parameterrecords (an array of items) and total (the server-reported total count)useGetOne calls reuse this cache)Most applications need to filter, sort, and paginate data instead of fetching everything at once. Pass these parameters to useGetList and Ginjou delegates the processing to your backend, reducing client-side overhead.
Example: Fetch 5 wooden products, sorted by ID descending
<script setup lang="ts">
import { useGetList } from '@ginjou/vue'
const { records, total } = useGetList({
resource: 'products',
pagination: {
current: 1,
pageSize: 5,
},
filters: [
{
field: 'material',
operator: 'eq',
value: 'wooden',
},
],
sorters: [
{
field: 'id',
order: 'desc',
},
],
})
</script>
<template>
<p>Showing 5 of {{ total }} wooden products</p>
<ul>
<li v-for="item in records" :key="item.id">
{{ item.name }}
</li>
</ul>
</template>
<!-- WIP -->
<script>
// ...
</script>
Building Complex Queries
Combine multiple filter conditions to create complex queries. For example, find products that are wooden OR have a price between 1000 and 2000:
const { records } = useGetList({
resource: 'products',
filters: [
{
field: 'material',
operator: 'eq',
value: 'wooden',
},
{
field: 'categoryId',
operator: 'eq',
value: '45',
},
{
field: 'price',
operator: 'between',
value: [1000, 2000],
},
],
})
Filter Operators
The available operators depend on your Fetcher implementation (REST providers, Supabase, Directus, etc.). Common operators include:
eq: Equalne: Not equalgt: Greater thangte: Greater than or equallt: Less thanlte: Less than or equalin: In arraynin: Not in arraycontains: Contains stringbetween: Between rangeUse useGetInfiniteList to support infinite scroll patterns. This composable automatically handles pagination by appending new records as the user scrolls.
<script setup lang="ts">
import { useGetInfiniteList } from '@ginjou/vue'
const { records, isFetching, hasNextPage, fetchNextPage } = useGetInfiniteList({
resource: 'products',
pagination: {
pageSize: 20,
},
})
</script>
<template>
<div>
<ul>
<li v-for="item in records" :key="item.id">
{{ item.name }} - ${{ item.price }}
</li>
</ul>
<button
v-if="hasNextPage"
:disabled="isFetching"
@click="fetchNextPage"
>
{{ isFetching ? 'Loading...' : 'Load More' }}
</button>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useGetInfiniteList composable:
resource name and pagination configurationrecords (accumulated array of all fetched items across pages)hasNextPage to determine if more data is availablefetchNextPage() function to load the next page of resultsUse useGetMany to fetch multiple specific records by their IDs. This is useful when you already know which records you need and want to fetch them in a single operation, rather than applying filters to a list.
<script setup lang="ts">
import { useGetMany } from '@ginjou/vue'
import { computed } from 'vue'
// IDs might come from a parent product or user selection
const recordIds = computed(() => ['id1', 'id2', 'id3'])
const { records, isFetching, isError } = useGetMany({
resource: 'products',
ids: recordIds,
})
</script>
<template>
<div v-if="isFetching">
Loading...
</div>
<div v-else-if="isError">
Error loading records
</div>
<div v-else>
<ul>
<li v-for="item in records" :key="item.id">
{{ item.name }} - ${{ item.price }}
</li>
</ul>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useGetMany composable:
resource and ids as required parametersrecords (an array of fetched items in the order of provided IDs)Common Use Cases for useGetMany:
Use useUpdateOne to update a single record. This composable manages mutation state, cache invalidation, and notifications automatically.
<script setup lang="ts">
import { useUpdateOne } from '@ginjou/vue'
const { mutate: update, isPending, isError } = useUpdateOne({
resource: 'products',
id: '123',
})
async function saveProduct(updatedData: any) {
try {
await update({ params: updatedData })
}
catch (error) {
console.error('Update failed:', error)
}
}
</script>
<template>
<button
:disabled="isPending"
@click="saveProduct({ price: 2000 })"
>
{{ isPending ? 'Saving...' : 'Save' }}
</button>
<div v-if="isError" class="error">
Failed to save product
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useUpdateOne composable:
resource, id) during initializationparams when calling mutate() or mutateAsync()Use useUpdateMany to update multiple records in a single operation. This is useful for bulk updates.
<script setup lang="ts">
import { useUpdateMany } from '@ginjou/vue'
const { mutate: updateBatch, isPending } = useUpdateMany({
resource: 'products',
})
async function updateProducts(updates: any[]) {
await updateBatch({ params: updates })
}
</script>
<template>
<button
:disabled="isPending"
@click="updateProducts([
{ id: '123', price: 1500 },
{ id: '456', price: 2000 },
])"
>
{{ isPending ? 'Updating...' : 'Update Selected' }}
</button>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useUpdateMany composable:
resource as the required parameterparams when calling mutate() or mutateAsync()Use useDeleteOne to delete a single record. This composable manages mutation state, cache invalidation, and notifications automatically.
<script setup lang="ts">
import { useDeleteOne } from '@ginjou/vue'
const { mutate: deleteRecord, isPending, isError } = useDeleteOne({
resource: 'products',
id: '123',
})
async function removeProduct() {
try {
await deleteRecord()
}
catch (error) {
console.error('Delete failed:', error)
}
}
</script>
<template>
<button
:disabled="isPending"
@click="removeProduct"
>
{{ isPending ? 'Deleting...' : 'Delete Product' }}
</button>
<div v-if="isError" class="error">
Failed to delete product
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useDeleteOne composable:
resource and id as required parameters during initializationparams when calling mutate() or mutateAsync()useUpdateOne)Use useDeleteMany to delete multiple records in a single operation. This is useful for bulk deletions.
<script setup lang="ts">
import { useDeleteMany } from '@ginjou/vue'
const { mutate: deleteBatch, isPending } = useDeleteMany({
resource: 'products',
})
async function removeProducts(ids: string[]) {
await deleteBatch({ params: ids })
}
</script>
<template>
<button
:disabled="isPending"
@click="removeProducts(['123', '456', '789'])"
>
{{ isPending ? 'Deleting...' : 'Delete Selected' }}
</button>
</template>
<!-- WIP -->
<script>
// ...
</script>
The useDeleteMany composable:
resource as the required parameterparams when calling mutate() or mutateAsync()You can use multiple fetchers in a single application. Each fetcher has its own configuration and connects to a different backend. Use the fetcherName parameter to specify which fetcher to use.
This pattern is useful when:
Example: Fetch from two different APIs
<script setup lang="ts">
import { useGetList } from '@ginjou/vue'
// Fetch products from the products API
const { records: products } = useGetList({
resource: 'products',
fetcherName: 'products-api',
})
// Fetch users from the users API (different backend)
const { records: users } = useGetList({
resource: 'users',
fetcherName: 'users-api',
})
</script>
<template>
<div>
<h2>Products ({{ products.length }})</h2>
<ul>
<li v-for="p in products" :key="p.id">
{{ p.name }}
</li>
</ul>
<h2>Users ({{ users.length }})</h2>
<ul>
<li v-for="u in users" :key="u.id">
{{ u.name }}
</li>
</ul>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
Set up multiple fetchers during application initialization (in app.vue for Nuxt or your root component for Vue), then reference them by name in any composable.
When you update, create, or delete data, Ginjou offers three strategies for handling the mutation and updating the cache. Choose the strategy that best fits your use case.
Updates happen on the server first, then the cache updates. If the server request fails, the cache remains unchanged and you can retry.
Pros:
Cons:
const { mutate: update } = useUpdateOne({
resource: 'products',
id: '123',
mutationMode: 'pessimistic', // Default
})
function save() {
update({ params: { price: 2000 } })
// UI shows loading... until server responds
}
Updates happen immediately in the cache, while the server request runs in the background. If the server request fails, the cache is rolled back to the previous state.
Pros:
Cons:
const { mutate: update } = useUpdateOne({
resource: 'products',
id: '123',
mutationMode: 'optimistic',
})
function save() {
update({ params: { price: 2000 } })
// UI updates immediately, then confirms with server
}
Updates happen immediately in the cache with a success notification that includes an "undo" button. Users can undo within a timeout window (default 5 seconds) before the server request is finalized.
Pros:
Cons:
const { mutate: update } = useUpdateOne({
resource: 'products',
id: '123',
mutationMode: 'undoable',
undoableTimeout: 5000, // Milliseconds
})
function save() {
update({ params: { price: 2000 } })
// UI updates, notification shows "Undo" button for 5 seconds
}
Comparison: User Experience Timeline
| Timeline | Pessimistic | Optimistic | Undoable |
|---|---|---|---|
| Click Save | ⏳ Loading state shows | ✅ UI updates instantly | ✅ UI updates instantly |
| 500ms after | ⏳ Still loading | ✅ Data displayed | ⏳ "Undo" button visible |
| Server responds (success) | ✅ UI updates | ✅ No change (already updated) | ⏳ "Undo" expires in 4.5s |
| Server rejects | ❌ Error shown, cache unchanged | ↩️ Rollback happens silently | ↩️ Rollback to previous value |
| Best for | Critical operations (payments, deletes) | Content updates, fast networks | Non-critical changes (tags, titles) |
Ginjou composables like useGetOne, useGetList, and useGetMany provide flexible ways to manage relationships between data entities.
A one-to-one relationship connects one record to exactly one other record. For example, each Product has exactly one ProductDetail.
Fetch related data using separate useGetOne calls:
<script setup lang="ts">
import { useGetOne } from '@ginjou/vue'
// Get the main product
const { record: product } = useGetOne({
resource: 'products',
id: '123',
})
// Get the related detail
const { record: detail } = useGetOne({
resource: 'product_details',
id: '123', // Same ID or use a foreign key
})
</script>
<template>
<div>
<h1>{{ product?.name }}</h1>
<p>Weight: {{ detail?.weight }}kg</p>
<p>Dimensions: {{ detail?.dimensions }}</p>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
A one-to-many relationship connects one record to multiple other records. For example, a Product has many Reviews.
Fetch related records by filtering the list query:
<script setup lang="ts">
import { useGetList } from '@ginjou/vue'
const productId = '123'
const { records: reviews, total } = useGetList({
resource: 'reviews',
filters: [
{
field: 'productId',
operator: 'eq',
value: productId,
},
],
})
</script>
<template>
<div>
<h2>Reviews ({{ total }})</h2>
<div v-for="review in reviews" :key="review.id" class="review">
<div class="rating">
★{{ review.rating }}
</div>
<p>{{ review.comment }}</p>
</div>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
A many-to-many relationship connects records from one entity to multiple records in another entity, and vice versa. For example, Products can have many Categories, and Categories can have many Products.
Fetch multiple records by ID using useGetMany. This is useful when you already have the related IDs from a junction table or relationship field:
<script setup lang="ts">
import { useGetMany, useGetOne } from '@ginjou/vue'
import { computed } from 'vue'
// Get the main product
const { record: product } = useGetOne({
resource: 'products',
id: '123',
})
// Extract category IDs from the product
const categoryIds = computed(() => product.value?.categoryIds ?? [])
// Fetch the category records
const { records: categories } = useGetMany({
resource: 'categories',
ids: categoryIds,
})
</script>
<template>
<div>
<h1>{{ product?.name }}</h1>
<h3>Categories</h3>
<ul>
<li v-for="cat in categories" :key="cat.id">
{{ cat.name }}
</li>
</ul>
</div>
</template>
<!-- WIP -->
<script>
// ...
</script>
When using useGetMany for many-to-many relationships:
useGetMany to fetch the actual recordsWhen you create, update, or delete data, the cached query results become outdated. Ginjou automatically invalidates relevant caches so they refetch from your backend, keeping your UI synchronized with your data.
Invalidation marks queries as "stale" based on target criteria. The next time a component accesses that query, TanStack Query refetches the data automatically. This happens:
Specify which cached queries to refresh using these target options:
| Target | Scope | Use Case |
|---|---|---|
all | All queries for a fetcher | Reset entire data layer |
resource | All queries for a specific resource | Resource-wide invalidation |
list | List queries only | After creating/deleting items |
many | "Many" queries for specific IDs | Batch operation changes |
one | Single-item queries for specific IDs | Individual item changes |
Most mutation composables automatically invalidate relevant caches based on the operation type. These defaults are usually correct for typical CRUD operations:
| Composable | Default Targets | Why |
|---|---|---|
useCreateOne, useCreateMany | ['list', 'many'] | New items appear in lists and batch queries |
useUpdateOne, useUpdateMany | ['list', 'many', 'one'] | Changed items appear everywhere they're displayed |
useDeleteOne, useDeleteMany | ['list', 'many'] | Deleted items removed from lists and batches |
Example: Invalidation Outcomes
When you update a product with ID 123 from price $100 to $200:
// Before update:
useGetList() // Returns: [{ id: 123, price: 100 }, { id: 456, price: 50 }]
useGetOne({ id: 123 }) // Returns: { id: 123, price: 100 }
useGetMany({ ids: [123, 456] }) // Returns: [{ id: 123, price: 100 }, { id: 456, price: 50 }]
const { mutate: update } = useUpdateOne({
resource: 'products',
id: '123',
invalidates: ['list', 'many', 'one'] // Default
})
update({ params: { price: 200 } })
// After update (mutations succeed):
useGetList() // ↩️ Refetches → Returns: [{ id: 123, price: 200 }, { id: 456, price: 50 }]
useGetOne({ id: 123 }) // ↩️ Refetches → Returns: { id: 123, price: 200 }
useGetMany({ ids: [123, 456] }) // ↩️ Refetches → Returns: [{ id: 123, price: 200 }, { id: 456, price: 50 }]
Effect of Different Invalidation Targets:
| Invalidation Target | List Query | One Query | Many Query |
|---|---|---|---|
['one'] | ❌ Stale | ✅ Refetch | ❌ Stale |
['list', 'one'] | ✅ Refetch | ✅ Refetch | ❌ Stale |
['list', 'many', 'one'] | ✅ Refetch | ✅ Refetch | ✅ Refetch |
['resource'] | ✅ Refetch | ✅ Refetch | ✅ Refetch |
Override the default targets by passing the invalidates option. Only invalidate specific targets to improve performance:
<script setup lang="ts">
import { useUpdateOne } from '@ginjou/vue'
// Only invalidate the specific item, not the list
// Use this when updates are frequent but list order/filters don't change
const { mutate: update } = useUpdateOne({
resource: 'products',
id: '123',
invalidates: ['one'],
})
function save() {
update({ params: { price: 2000 } })
}
</script>
<!-- WIP -->
<script>
// ...
</script>
Choose invalidation targets based on your application's behavior:
['one'] if updates don't change list order or visibility['one'] + manual list refresh for complex scenarios['resource'] when many operations affect the same resourceTo disable automatic invalidation and manage it manually:
<script setup lang="ts">
import { useQueryClientContext, useUpdateOne } from '@ginjou/vue'
const queryClient = useQueryClientContext()
const { mutate: update } = useUpdateOne({
resource: 'products',
id: '123',
invalidates: false, // Disable automatic invalidation
})
async function save() {
await update({ params: { price: 2000 } })
// Manual invalidation - do exactly what you need
await queryClient.invalidateQueries({
queryKey: ['product-api', 'products'],
})
}
</script>
<!-- WIP -->
<script>
// ...
</script>
Manual invalidation is useful for:
A complete list of all data composables and their purposes:
| Composable | Operation | Use Case |
|---|---|---|
useGetOne | Read | Fetch a single record by ID |
useGetList | Read | Fetch multiple records with pagination |
useGetInfiniteList | Read | Fetch records with infinite scroll |
useGetMany | Read | Fetch multiple specific records by IDs |
useCreateOne | Create | Create a new record |
useCreateMany | Create | Create multiple records in batch |
useUpdateOne | Update | Update a single record |
useUpdateMany | Update | Update multiple records in batch |
useDeleteOne | Delete | Delete a single record |
useDeleteMany | Delete | Delete multiple records in batch |
useCustom | Custom | Make custom read API requests |
useCustomMutation | Custom | Make custom mutation API requests |
All composables follow this lifecycle pattern: