gen-query
v1.8.0
Published
Query with tanstack using generics
Readme
gen-query
A powerful Nuxt module for type-safe API queries using TanStack Query (Vue Query) with full TypeScript support and automatic cache management.
Features
- 🔥 Type-safe - Full TypeScript support with generics
- 🚀 TanStack Query - Built on TanStack Query for powerful data fetching and caching
- 🎯 Composables-first - Easy-to-use Vue composables for all operations
- 📄 Pagination - Built-in infinite scroll and pagination support
- 🔄 Smart Caching - Automatic cache invalidation with configurable update strategies
- 🔐 Authentication - Optional token-based authentication
- 🎨 Flexible Filtering - Advanced filtering with multiple match modes
- 📦 Lightweight - Minimal dependencies
Quick Setup
Install the module:
npx nuxi module add gen-queryConfiguration
Configure in nuxt.config.ts:
export default defineNuxtConfig({
modules: ['gen-query'],
genQuery: {
baseURL: 'https://api.example.com', // API base URL
cachedPages: 4, // Max cached pages for infinite queries
update: UpdateStrategy.Invalidate, // Cache update strategy
},
})Configuration Options
| Option | Type | Default | Description |
| ------------- | ---------------- | ------------ | ------------------------------------------------------------------------------------------ |
| baseURL | string | '' | Base URL for all API requests. Can also be set via NUXT_PUBLIC_API_BASE_URL env variable |
| cachedPages | number | 4 | Maximum number of pages to cache in infinite queries |
| update | UpdateStrategy | Invalidate | Strategy for updating cache after mutations |
Update Strategies
The module supports three cache update strategies:
UpdateStrategy.None- No automatic cache updates. You manage cache manually.UpdateStrategy.Invalidate- Invalidates and refetches affected queries after mutations (recommended).UpdateStrategy.Optimistic- Immediately updates cache optimistically before server confirms.
import { UpdateStrategy } from 'gen-query'
export default defineNuxtConfig({
genQuery: {
update: UpdateStrategy.Optimistic, // Use optimistic updates
},
})Quick Start
<script setup lang="ts">
import type { Entity } from 'gen-query'
// Define your entity type
interface Product extends Entity<number> {
name: string
price: number
}
// Fetch all products
const productQuery = useMultipleQuery<Product, number>('products')
// Create a new product
productQuery.create.mutate({ name: 'New Product', price: 99.99 })
</script>
<template>
<div v-if="productQuery.read.isLoading.value">Loading...</div>
<div v-else>
<div v-for="product in productQuery.read.data.value" :key="product.id">
{{ product.name }} - ${{ product.price }}
</div>
</div>
</template>Core Concepts
Composables
gen-query provides four main composables:
useLoginService- Handle user authenticationuseSingleQuery- CRUD operations for a single entity by IDuseMultipleQuery- CRUD operations for collectionsusePaginatedQuery- Paginated data with filtering and sorting
All composables return TanStack Query objects with reactive properties for data, loading states, errors, and mutation functions.
Queries vs Mutations
- Queries (
read,list,page) - Fetch data with automatic caching and background refetching - Mutations (
create,update,del) - Modify data with automatic cache updates based on your strategy
Usage Guide
Authentication
Use useLoginService for user authentication:
<script setup lang="ts">
import type { Login, User } from 'gen-query'
const loginService = useLoginService('auth') // 'auth' is the resource endpoint
const credentials: Login = {
username: '[email protected]',
password: 'password123',
}
const handleLogin = () => {
loginService.login.mutate(credentials, {
onSuccess: (user: User) => {
console.log('Logged in:', user.token)
// Store token for subsequent requests
localStorage.setItem('token', user.token)
},
onError: (error) => {
console.error('Login failed:', error.message)
},
})
}
</script>
<template>
<form @submit.prevent="handleLogin">
<button type="submit" :disabled="loginService.login.isPending.value">
{{ loginService.login.isPending.value ? 'Logging in...' : 'Login' }}
</button>
<div v-if="loginService.login.isError.value" class="error">
{{ loginService.login.error.value?.message }}
</div>
</form>
</template>Single Entity Operations
Use useSingleQuery to work with a single entity by ID:
<script setup lang="ts">
import type { Entity } from 'gen-query'
interface Product extends Entity<number> {
name: string
price: number
category: string
description: string
}
const productId = ref(1)
const token = ref(localStorage.getItem('token') || undefined)
const productQuery = useSingleQuery<Product, number>('products', productId, token)
const handleUpdate = () => {
if (!productQuery.read.data.value) return
productQuery.update.mutate(
{
...productQuery.read.data.value,
price: productQuery.read.data.value.price * 0.9, // 10% discount
},
{
onSuccess: () => {
console.log('Product updated!')
},
}
)
}
const handleDelete = () => {
if (!productQuery.read.data.value) return
productQuery.del.mutate(productQuery.read.data.value, {
onSuccess: () => {
console.log('Product deleted!')
// Navigate away or update UI
},
})
}
</script>
<template>
<div v-if="productQuery.read.isLoading.value">Loading...</div>
<div v-else-if="productQuery.read.isError.value">
Error: {{ productQuery.read.error.value?.message }}
</div>
<div v-else-if="productQuery.read.data.value">
<h1>{{ productQuery.read.data.value.name }}</h1>
<p>{{ productQuery.read.data.value.description }}</p>
<p>Price: ${{ productQuery.read.data.value.price }}</p>
<button @click="handleUpdate" :disabled="productQuery.update.isPending.value">
{{ productQuery.update.isPending.value ? 'Updating...' : 'Apply 10% Discount' }}
</button>
<button @click="handleDelete" :disabled="productQuery.del.isPending.value">
{{ productQuery.del.isPending.value ? 'Deleting...' : 'Delete Product' }}
</button>
</div>
</template>Collection Operations
Use useMultipleQuery for CRUD operations on collections:
<script setup lang="ts">
import type { Entity } from 'gen-query'
interface Product extends Entity<number> {
name: string
price: number
category: string
}
const token = ref(localStorage.getItem('token') || undefined)
const productQuery = useMultipleQuery<Product, number>('products', token)
const newProduct = ref({ name: '', price: 0, category: '' })
const handleCreate = () => {
productQuery.create.mutate(newProduct.value, {
onSuccess: (created) => {
console.log('Created:', created)
newProduct.value = { name: '', price: 0, category: '' }
},
})
}
const handleUpdate = (product: Product) => {
productQuery.update.mutate({
...product,
price: product.price * 1.1, // 10% increase
})
}
const handleDelete = (product: Product) => {
if (confirm(`Delete ${product.name}?`)) {
productQuery.del.mutate(product)
}
}
</script>
<template>
<div>
<!-- Create Form -->
<form @submit.prevent="handleCreate">
<input v-model="newProduct.name" placeholder="Name" required />
<input v-model.number="newProduct.price" type="number" placeholder="Price" required />
<input v-model="newProduct.category" placeholder="Category" required />
<button type="submit" :disabled="productQuery.create.isPending.value">
{{ productQuery.create.isPending.value ? 'Creating...' : 'Create Product' }}
</button>
</form>
<!-- Product List -->
<div v-if="productQuery.read.isLoading.value">Loading products...</div>
<div v-else-if="productQuery.read.isError.value">
Error: {{ productQuery.read.error.value?.message }}
</div>
<div v-else>
<div v-for="product in productQuery.read.data.value" :key="product.id" class="product-card">
<h3>{{ product.name }}</h3>
<p>Price: ${{ product.price }}</p>
<p>Category: {{ product.category }}</p>
<button @click="handleUpdate(product)" :disabled="productQuery.update.isPending.value">
Increase Price 10%
</button>
<button @click="handleDelete(product)" :disabled="productQuery.del.isPending.value">
Delete
</button>
</div>
<button @click="productQuery.read.refetch()" :disabled="productQuery.read.isFetching.value">
{{ productQuery.read.isFetching.value ? 'Refreshing...' : 'Refresh List' }}
</button>
</div>
</div>
</template>Paginated Queries with Filtering
Use usePaginatedQuery for paginated data with advanced filtering and sorting:
<script setup lang="ts">
import { Pageable, Filters } from 'gen-query'
import type { Entity } from 'gen-query'
interface Product extends Entity<number> {
name: string
price: number
category: string
stock: number
createdAt: Date
}
const token = ref(localStorage.getItem('token') || undefined)
// Configure pagination
const pageable = new Pageable(
0, // page number (0-indexed)
20, // page size
[{ property: 'createdAt', direction: 'desc' }] // sort by newest first
)
// Configure filters
const filters = ref(new Filters())
// Filter by price range
filters.value.price = {
operator: 'and',
constraints: [
{ matchMode: 'gte', value: 50 }, // price >= 50
{ matchMode: 'lte', value: 500 }, // price <= 500
],
}
// Filter by category
filters.value.category = {
operator: 'and',
constraints: [{ matchMode: 'eq', value: 'electronics' }],
}
// Filter by stock availability
filters.value.stock = {
operator: 'and',
constraints: [{ matchMode: 'gt', value: 0 }], // in stock
}
const productQuery = usePaginatedQuery<Product, number>('products', pageable, filters, token)
// Access paginated data
const firstPage = computed(() => productQuery.read.data.value?.pages[0])
const products = computed(() => firstPage.value?.content ?? [])
const totalPages = computed(() => firstPage.value?.page.totalPages ?? 0)
const totalElements = computed(() => firstPage.value?.page.totalElements ?? 0)
const currentPage = computed(() => firstPage.value?.page.number ?? 0)
// Get all products from all loaded pages (for infinite scroll)
const allProducts = computed(
() => productQuery.read.data.value?.pages.flatMap((page) => page.content) ?? []
)
// Update filters dynamically
const updatePriceFilter = (min: number, max: number) => {
filters.value.price = {
operator: 'and',
constraints: [
{ matchMode: 'gte', value: min },
{ matchMode: 'lte', value: max },
],
}
}
const updateCategoryFilter = (category: string) => {
if (category) {
filters.value.category = {
operator: 'and',
constraints: [{ matchMode: 'eq', value: category }],
}
} else {
delete filters.value.category
}
}
const searchByName = (searchTerm: string) => {
if (searchTerm) {
filters.value.name = {
operator: 'and',
constraints: [{ matchMode: 'contains', value: searchTerm }],
}
} else {
delete filters.value.name
}
}
</script>
<template>
<div>
<!-- Filters -->
<div class="filters">
<input
type="text"
placeholder="Search by name..."
@input="searchByName($event.target.value)"
/>
<select @change="updateCategoryFilter($event.target.value)">
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="accessories">Accessories</option>
<option value="computers">Computers</option>
</select>
<div>
Price:
<input
type="number"
placeholder="Min"
@change="updatePriceFilter($event.target.value, 500)"
/>
-
<input
type="number"
placeholder="Max"
@change="updatePriceFilter(50, $event.target.value)"
/>
</div>
</div>
<!-- Loading State -->
<div v-if="productQuery.read.isLoading.value">Loading products...</div>
<!-- Error State -->
<div v-else-if="productQuery.read.isError.value" class="error">
Error: {{ productQuery.read.error.value?.message }}
</div>
<!-- Product List -->
<div v-else>
<div class="summary">
Showing {{ products.length }} of {{ totalElements }} products (Page {{ currentPage + 1 }} of
{{ totalPages }})
</div>
<div v-for="product in products" :key="product.id" class="product-card">
<h3>{{ product.name }}</h3>
<p>Price: ${{ product.price }}</p>
<p>Category: {{ product.category }}</p>
<p>Stock: {{ product.stock }}</p>
<p>Added: {{ new Date(product.createdAt).toLocaleDateString() }}</p>
</div>
<!-- Pagination Controls -->
<div class="pagination">
<button
@click="productQuery.read.fetchPreviousPage()"
:disabled="
!productQuery.read.hasPreviousPage.value ||
productQuery.read.isFetchingPreviousPage.value
"
>
{{ productQuery.read.isFetchingPreviousPage.value ? 'Loading...' : 'Previous Page' }}
</button>
<button
@click="productQuery.read.fetchNextPage()"
:disabled="
!productQuery.read.hasNextPage.value || productQuery.read.isFetchingNextPage.value
"
>
{{ productQuery.read.isFetchingNextPage.value ? 'Loading...' : 'Next Page' }}
</button>
</div>
</div>
</div>
</template>Infinite Scroll
For infinite scroll, use all loaded pages:
<script setup lang="ts">
import { Pageable, Filters } from 'gen-query'
import type { Entity } from 'gen-query'
interface Post extends Entity<number> {
title: string
content: string
author: string
}
const pageable = new Pageable(0, 10)
const filters = ref(new Filters())
const postQuery = usePaginatedQuery<Post, number>('posts', pageable, filters)
// Get all posts from all loaded pages
const allPosts = computed(
() => postQuery.read.data.value?.pages.flatMap((page) => page.content) ?? []
)
// Infinite scroll handler
const handleScroll = () => {
const scrollPosition = window.innerHeight + window.scrollY
const threshold = document.documentElement.scrollHeight - 100
if (
scrollPosition >= threshold &&
postQuery.read.hasNextPage.value &&
!postQuery.read.isFetchingNextPage.value
) {
postQuery.read.fetchNextPage()
}
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<template>
<div>
<div v-if="postQuery.read.isLoading.value">Loading...</div>
<div v-else>
<div v-for="post in allPosts" :key="post.id" class="post">
<h2>{{ post.title }}</h2>
<p>{{ post.content }}</p>
<small>By {{ post.author }}</small>
</div>
<div v-if="postQuery.read.isFetchingNextPage.value" class="loading">
Loading more posts...
</div>
<div v-else-if="!postQuery.read.hasNextPage.value" class="end">No more posts to load</div>
</div>
</div>
</template>API Reference
Composables
useLoginService(resource?: string)
Creates a login service for authentication.
Parameters:
resource(optional): API endpoint for login. Default:'auth'
Returns:
{
login: {
mutate: (credentials: Login, options?) => void
isPending: Ref<boolean>
isError: Ref<boolean>
isSuccess: Ref<boolean>
error: Ref<ApiError | null>
data: Ref<User | undefined>
}
}useSingleQuery<T, K>(resource: string, id: Ref<K>, token?: MaybeRefOrGetter<string | undefined>)
Fetches and manages a single entity by ID.
Type Parameters:
T: Entity type extendingEntity<K>K: ID type (e.g.,number,string)
Parameters:
resource: API endpoint (e.g.,'products')id: Reactive reference to entity IDtoken(optional): Authentication token (reactive or getter)
Returns:
{
read: {
data: Ref<T | undefined>
error: Ref<ApiError | null>
isLoading: Ref<boolean>
isError: Ref<boolean>
isSuccess: Ref<boolean>
isFetching: Ref<boolean>
status: Ref<'loading' | 'error' | 'success'>
refetch: () => Promise<void>
},
create: {
mutate: (entity: T, options?) => void
isPending: Ref<boolean>
isError: Ref<boolean>
isSuccess: Ref<boolean>
error: Ref<ApiError | null>
},
update: { /* same as create */ },
del: { /* same as create */ }
}useMultipleQuery<T, K>(resource: string, token?: MaybeRefOrGetter<string | undefined>)
Provides CRUD operations for a collection of entities.
Type Parameters:
T: Entity type extendingEntity<K>K: ID type
Parameters:
resource: API endpointtoken(optional): Authentication token
Returns:
{
read: {
data: Ref<T[] | undefined>
error: Ref<ApiError | null>
isLoading: Ref<boolean>
isError: Ref<boolean>
isSuccess: Ref<boolean>
isFetching: Ref<boolean>
refetch: () => Promise<void>
},
create: { /* mutation object */ },
update: { /* mutation object */ },
del: { /* mutation object */ }
}usePaginatedQuery<T, K>(resource: string, pageable: Pageable, filters: Ref<Filters>, token?: MaybeRefOrGetter<string | undefined>)
Fetches paginated entities with filtering and sorting.
Type Parameters:
T: Entity type extendingEntity<K>K: ID type
Parameters:
resource: API endpointpageable: Pagination configurationfilters: Reactive filterstoken(optional): Authentication token
Returns:
{
read: {
data: Ref<InfiniteData<Page<T>> | undefined>
error: Ref<ApiError | null>
isLoading: Ref<boolean>
isError: Ref<boolean>
isSuccess: Ref<boolean>
isFetching: Ref<boolean>
isFetchingNextPage: Ref<boolean>
isFetchingPreviousPage: Ref<boolean>
hasNextPage: Ref<boolean>
hasPreviousPage: Ref<boolean>
fetchNextPage: () => Promise<void>
fetchPreviousPage: () => Promise<void>
refetch: () => Promise<void>
},
create: { /* mutation object */ },
update: { /* mutation object */ },
del: { /* mutation object */ }
}Types
Entity<K>
Base interface for entities with an ID.
interface Entity<K> {
id: K
}Example:
interface Product extends Entity<number> {
name: string
price: number
}Login
Login credentials.
type Login = {
username: string
password: string
}User
User information returned after login.
type User = {
username: string
password: string
fullName: string
email: string
token: string
}Page<T>
Paginated response structure.
type Page<T> = {
page: {
number: number // Current page (0-indexed)
size: number // Items per page
totalElements: number // Total items across all pages
totalPages: number // Total number of pages
}
content: T[] // Items in current page
}Sort
Sorting configuration.
type Sort = {
property: string // Field to sort by
direction: 'asc' | 'desc' // Sort direction
}FilterItem
Filter configuration.
type FilterItem = {
operator: string // 'and' or 'or'
constraints: Constraint[]
}Constraint
Filter constraint.
type Constraint = {
matchMode: string // eq, ne, lt, lte, gt, gte, contains, startsWith, endsWith, in
value: unknown // Filter value
}Classes
Pageable
Pagination configuration class.
class Pageable {
constructor(page: number = 0, size: number = 30, sort: Sort[] = [])
toQueryParams(): string
}Example:
const pageable = new Pageable(
0, // first page
20, // 20 items per page
[
{ property: 'name', direction: 'asc' },
{ property: 'price', direction: 'desc' },
]
)Filters
Filter configuration class.
class Filters {
[key: string]: FilterItem
toQueryParams(): string
}Example:
const filters = new Filters()
// Single constraint
filters.category = {
operator: 'and',
constraints: [{ matchMode: 'eq', value: 'electronics' }],
}
// Multiple constraints (AND)
filters.price = {
operator: 'and',
constraints: [
{ matchMode: 'gte', value: 100 },
{ matchMode: 'lte', value: 500 },
],
}
// Multiple constraints (OR)
filters.status = {
operator: 'or',
constraints: [
{ matchMode: 'eq', value: 'active' },
{ matchMode: 'eq', value: 'pending' },
],
}
// String matching
filters.name = {
operator: 'and',
constraints: [{ matchMode: 'contains', value: 'laptop' }],
}
// Date filtering
filters.createdAt = {
operator: 'and',
constraints: [{ matchMode: 'gte', value: new Date('2024-01-01') }],
}Available Match Modes:
| Match Mode | Description | Example |
| ------------ | --------------------- | ---------------------------------------------- |
| eq | Equals | { matchMode: 'eq', value: 'active' } |
| ne | Not equals | { matchMode: 'ne', value: 'deleted' } |
| lt | Less than | { matchMode: 'lt', value: 100 } |
| lte | Less than or equal | { matchMode: 'lte', value: 100 } |
| gt | Greater than | { matchMode: 'gt', value: 50 } |
| gte | Greater than or equal | { matchMode: 'gte', value: 50 } |
| contains | Contains substring | { matchMode: 'contains', value: 'laptop' } |
| startsWith | Starts with | { matchMode: 'startsWith', value: 'Pro' } |
| endsWith | Ends with | { matchMode: 'endsWith', value: 'Pro' } |
| in | In list | { matchMode: 'in', value: 'active,pending' } |
ApiError
Custom error class for API errors.
class ApiError extends Error {
timestamp: Date
type: string
statusCode: number
status: string
content?: object
stack?: string
}Example:
const { error } = productQuery.read
if (error.value) {
console.error('Error:', error.value.message)
console.error('Status:', error.value.statusCode)
console.error('Details:', error.value.content)
}Enums
UpdateStrategy
Cache update strategy for mutations.
enum UpdateStrategy {
None, // No automatic cache updates
Invalidate, // Invalidate and refetch (recommended)
Optimistic, // Optimistic updates
}Usage:
import { UpdateStrategy } from 'gen-query'
export default defineNuxtConfig({
genQuery: {
update: UpdateStrategy.Optimistic,
},
})Backend Requirements
Your backend API must follow the REST specification detailed in BACKEND_API.md.
Key requirements:
- RESTful endpoints with JSON request/response
- Standard CRUD operations (GET, POST, PUT, DELETE)
- Pagination endpoint with
/pagesuffix - Support for filter and sort query parameters
- Consistent error response format
- ISO 8601 date format
- Optional Bearer token authentication
Development
# Install dependencies
npm install
# Generate type stubs
npm run dev:prepare
# Develop with playground
npm run dev
# Build playground
npm run dev:build
# Run ESLint
npm run lint
# Run type checking
npm run test:types
# Run tests
npm run test
npm run test:watch
# Release new version
npm run releaseLicense
MIT License - see LICENSE for details
