Loading States
Managing loading states effectively is crucial for good user experience. @kitbag/query provides granular loading state management for both queries and mutations.
Query Loading States
Basic Loading States
vue
<template>
<div>
<!-- Initial loading -->
<div v-if="user.executing && !user.data">
<LoadingSpinner />
<p>Loading user profile...</p>
</div>
<!-- Background refresh -->
<div v-else-if="user.executing && user.data">
<UserProfile :user="user.data" />
<div class="refresh-indicator">Updating...</div>
</div>
<!-- Loaded -->
<div v-else-if="user.data">
<UserProfile :user="user.data" />
</div>
<!-- Error -->
<div v-else-if="user.error">
<ErrorMessage :error="user.error" />
</div>
</div>
</template>
<script setup lang="ts">
const user = useQuery(userQuery, { params: [userId] })
</script>
Loading State Properties
ts
interface QueryResult<TData> {
executing: Ref<boolean> // Currently fetching data
executed: Ref<boolean> // Has been executed at least once
data: Ref<TData> // Current data
error: Ref<unknown> // Current error
}
Computed Loading States
Create computed properties for different loading scenarios:
vue
<script setup lang="ts">
const user = useQuery(userQuery, { params: [userId] })
// Different loading states
const isInitialLoading = computed(() =>
user.executing.value && !user.executed.value
)
const isRefreshing = computed(() =>
user.executing.value && user.executed.value && user.data.value
)
const isReloading = computed(() =>
user.executing.value && user.executed.value && !user.data.value
)
const isEmpty = computed(() =>
!user.executing.value && !user.data.value && !user.error.value
)
</script>
<template>
<div>
<!-- Initial load -->
<div v-if="isInitialLoading" class="initial-loading">
<SkeletonLoader />
</div>
<!-- Background refresh -->
<div v-else-if="isRefreshing">
<UserProfile :user="user.data" />
<div class="refresh-bar">Updating...</div>
</div>
<!-- Reloading after error -->
<div v-else-if="isReloading" class="reloading">
<LoadingSpinner />
<p>Retrying...</p>
</div>
<!-- Content loaded -->
<div v-else-if="user.data">
<UserProfile :user="user.data" />
</div>
</div>
</template>
Mutation Loading States
Basic Mutation Loading
vue
<template>
<form @submit.prevent="handleSubmit">
<input v-model="userData.name" placeholder="Name" />
<input v-model="userData.email" placeholder="Email" />
<button
type="submit"
:disabled="createUser.executing"
:class="{ 'loading': createUser.executing }"
>
<LoadingSpinner v-if="createUser.executing" size="small" />
{{ createUser.executing ? 'Creating...' : 'Create User' }}
</button>
<!-- Success state -->
<div v-if="createUser.data && !createUser.executing" class="success">
User created successfully!
</div>
<!-- Error state -->
<div v-if="createUser.error" class="error">
{{ createUser.error.message }}
</div>
</form>
</template>
<script setup lang="ts">
const userData = reactive({ name: '', email: '' })
const createUser = useMutation(createUserMutation)
async function handleSubmit() {
try {
await createUser.mutate(userData)
// Reset form on success
userData.name = ''
userData.email = ''
} catch (error) {
// Error is already available in createUser.error
}
}
</script>
Multiple Mutation States
Handle multiple mutations with different loading states:
vue
<template>
<div>
<button
@click="handleCreate"
:disabled="isAnyLoading"
class="btn-primary"
>
<LoadingSpinner v-if="createUser.executing" size="small" />
Create
</button>
<button
@click="handleUpdate"
:disabled="isAnyLoading"
class="btn-secondary"
>
<LoadingSpinner v-if="updateUser.executing" size="small" />
Update
</button>
<button
@click="handleDelete"
:disabled="isAnyLoading"
class="btn-danger"
>
<LoadingSpinner v-if="deleteUser.executing" size="small" />
Delete
</button>
<!-- Global loading indicator -->
<div v-if="isAnyLoading" class="global-loading">
Processing...
</div>
</div>
</template>
<script setup lang="ts">
const createUser = useMutation(createUserMutation)
const updateUser = useMutation(updateUserMutation)
const deleteUser = useMutation(deleteUserMutation)
const isAnyLoading = computed(() =>
createUser.executing.value ||
updateUser.executing.value ||
deleteUser.executing.value
)
</script>
Loading UI Components
Skeleton Loaders
Create skeleton loaders for better perceived performance:
vue
<!-- SkeletonLoader.vue -->
<template>
<div class="skeleton-loader">
<div class="skeleton-header">
<div class="skeleton-avatar"></div>
<div class="skeleton-text">
<div class="skeleton-line short"></div>
<div class="skeleton-line medium"></div>
</div>
</div>
<div class="skeleton-content">
<div class="skeleton-line long"></div>
<div class="skeleton-line medium"></div>
<div class="skeleton-line short"></div>
</div>
</div>
</template>
<style scoped>
.skeleton-loader {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-line {
height: 16px;
background: #e2e8f0;
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-line.short { width: 60%; }
.skeleton-line.medium { width: 80%; }
.skeleton-line.long { width: 100%; }
.skeleton-avatar {
width: 48px;
height: 48px;
background: #e2e8f0;
border-radius: 50%;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
Loading Spinner Component
vue
<!-- LoadingSpinner.vue -->
<template>
<div :class="['spinner', `spinner-${size}`]">
<div class="spinner-circle"></div>
</div>
</template>
<script setup lang="ts">
interface Props {
size?: 'small' | 'medium' | 'large'
}
withDefaults(defineProps<Props>(), {
size: 'medium'
})
</script>
<style scoped>
.spinner {
display: inline-block;
}
.spinner-circle {
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner-small .spinner-circle {
width: 16px;
height: 16px;
}
.spinner-medium .spinner-circle {
width: 32px;
height: 32px;
}
.spinner-large .spinner-circle {
width: 48px;
height: 48px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
Progressive Loading
Show content progressively as it loads:
vue
<template>
<div class="progressive-loader">
<!-- Always show basic structure -->
<div class="user-card">
<div class="user-avatar">
<img
v-if="user.data?.avatar"
:src="user.data.avatar"
:alt="user.data?.name"
/>
<div v-else class="avatar-placeholder">
{{ user.data?.name?.[0] || '?' }}
</div>
</div>
<div class="user-info">
<h2 v-if="user.data?.name">{{ user.data.name }}</h2>
<div v-else class="skeleton-line short"></div>
<p v-if="user.data?.email">{{ user.data.email }}</p>
<div v-else class="skeleton-line medium"></div>
</div>
<!-- Additional data loads separately -->
<div class="user-stats">
<div v-if="userStats.data">
<span>Posts: {{ userStats.data.posts }}</span>
<span>Followers: {{ userStats.data.followers }}</span>
</div>
<div v-else-if="userStats.executing">
<LoadingSpinner size="small" />
Loading stats...
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const user = useQuery(userQuery, { params: [userId] })
const userStats = useQuery(userStatsQuery, {
params: computed(() => user.data.value ? [user.data.value.id] : null)
})
</script>
Advanced Loading Patterns
Suspense-like Loading
Create a suspense-like component for loading states:
vue
<!-- QuerySuspense.vue -->
<template>
<div>
<slot v-if="!isLoading" />
<div v-else class="suspense-fallback">
<slot name="fallback">
<LoadingSpinner />
<p>Loading...</p>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
queries: Array<{ executing: Ref<boolean>, data: Ref<any> }>
}
const props = defineProps<Props>()
const isLoading = computed(() =>
props.queries.some(query =>
query.executing.value && !query.data.value
)
)
</script>
Usage:
vue
<template>
<QuerySuspense :queries="[user, posts, settings]">
<template #fallback>
<SkeletonLoader />
</template>
<!-- This renders only when all queries have data -->
<UserDashboard
:user="user.data"
:posts="posts.data"
:settings="settings.data"
/>
</QuerySuspense>
</template>
Staggered Loading
Show content in stages:
vue
<template>
<div class="staggered-content">
<!-- Stage 1: Always show immediately -->
<div class="stage-1">
<h1>User Dashboard</h1>
</div>
<!-- Stage 2: Show when user data loads -->
<div v-if="user.data" class="stage-2">
<UserProfile :user="user.data" />
</div>
<!-- Stage 3: Show when additional data loads -->
<div v-if="user.data && posts.data" class="stage-3">
<UserPosts :posts="posts.data" />
</div>
<!-- Stage 4: Show when everything loads -->
<div v-if="user.data && posts.data && stats.data" class="stage-4">
<UserStats :stats="stats.data" />
</div>
<!-- Loading indicators for pending stages -->
<div v-if="!user.data" class="loading-stage">
<SkeletonLoader />
</div>
<div v-else-if="!posts.data" class="loading-stage">
<LoadingSpinner size="small" /> Loading posts...
</div>
<div v-else-if="!stats.data" class="loading-stage">
<LoadingSpinner size="small" /> Loading statistics...
</div>
</div>
</template>
Conditional Loading States
Show different loading states based on context:
vue
<script setup lang="ts">
const searchTerm = ref('')
const searchResults = useQuery(searchQuery, {
params: computed(() => searchTerm.value ? [searchTerm.value] : null)
})
const loadingState = computed(() => {
if (!searchTerm.value) return 'empty'
if (searchResults.executing.value && !searchResults.data.value) return 'searching'
if (searchResults.executing.value && searchResults.data.value) return 'refreshing'
if (searchResults.data.value?.length === 0) return 'no-results'
if (searchResults.data.value) return 'results'
if (searchResults.error.value) return 'error'
return 'idle'
})
</script>
<template>
<div>
<input v-model="searchTerm" placeholder="Search users..." />
<div class="search-results">
<!-- Empty state -->
<div v-if="loadingState === 'empty'" class="empty-state">
<SearchIcon />
<p>Enter a search term to get started</p>
</div>
<!-- Searching -->
<div v-else-if="loadingState === 'searching'" class="searching">
<LoadingSpinner />
<p>Searching...</p>
</div>
<!-- Refreshing results -->
<div v-else-if="loadingState === 'refreshing'">
<SearchResults :results="searchResults.data" />
<div class="refresh-indicator">Updating results...</div>
</div>
<!-- No results -->
<div v-else-if="loadingState === 'no-results'" class="no-results">
<p>No results found for "{{ searchTerm }}"</p>
</div>
<!-- Results -->
<div v-else-if="loadingState === 'results'">
<SearchResults :results="searchResults.data" />
</div>
<!-- Error -->
<div v-else-if="loadingState === 'error'" class="error">
<p>Search failed: {{ searchResults.error.message }}</p>
</div>
</div>
</div>
</template>
Loading State Management
Global Loading State
Track global loading across the app:
ts
// composables/useGlobalLoading.ts
import { ref, computed } from 'vue'
const activeRequests = ref(new Set<string>())
export function useGlobalLoading() {
const isLoading = computed(() => activeRequests.value.size > 0)
function startLoading(id: string) {
activeRequests.value.add(id)
}
function stopLoading(id: string) {
activeRequests.value.delete(id)
}
return {
isLoading,
startLoading,
stopLoading
}
}
// Use in queries
const { startLoading, stopLoading } = useGlobalLoading()
const user = useQuery(userQuery, {
params: [userId],
onExecute: () => startLoading('user'),
onSuccess: () => stopLoading('user'),
onError: () => stopLoading('user')
})
Loading State Persistence
Maintain loading states across route changes:
ts
// composables/usePersistedLoading.ts
import { ref } from 'vue'
const loadingStates = ref(new Map<string, boolean>())
export function usePersistedLoading(key: string) {
const isLoading = computed({
get: () => loadingStates.value.get(key) || false,
set: (value) => {
if (value) {
loadingStates.value.set(key, true)
} else {
loadingStates.value.delete(key)
}
}
})
return { isLoading }
}
Best Practices
1. Show Immediate Feedback
vue
<!-- ✅ Good - Immediate loading state -->
<button @click="handleSubmit" :disabled="loading">
<LoadingSpinner v-if="loading" size="small" />
{{ loading ? 'Saving...' : 'Save' }}
</button>
<!-- ❌ Bad - No loading feedback -->
<button @click="handleSubmit">Save</button>
2. Use Appropriate Loading Indicators
vue
<!-- ✅ Skeleton for initial loads -->
<SkeletonLoader v-if="!user.data && user.executing" />
<!-- ✅ Subtle indicator for background updates -->
<div v-if="user.executing && user.data" class="refresh-indicator">
Updating...
</div>
<!-- ✅ Spinner for explicit actions -->
<LoadingSpinner v-if="mutation.executing" />
3. Prevent Multiple Submissions
vue
<!-- ✅ Disable buttons during loading -->
<button
@click="handleSubmit"
:disabled="createUser.executing"
>
Submit
</button>
4. Show Progress When Possible
vue
<!-- ✅ Show upload progress -->
<div v-if="uploadFile.executing" class="upload-progress">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${uploadProgress}%` }"
></div>
</div>
<span>{{ uploadProgress }}% uploaded</span>
</div>
5. Handle Loading State Cleanup
vue
<script setup lang="ts">
const query = useQuery(userQuery, { params: [userId] })
// Clean up loading states on unmount
onUnmounted(() => {
if (query.executing.value) {
query.dispose()
}
})
</script>
Accessibility
Loading State Announcements
vue
<template>
<div>
<!-- Screen reader announcements -->
<div
v-if="user.executing"
aria-live="polite"
aria-busy="true"
class="sr-only"
>
Loading user profile
</div>
<div
v-if="user.data && !user.executing"
aria-live="polite"
class="sr-only"
>
User profile loaded
</div>
<!-- Visual loading indicator -->
<LoadingSpinner
v-if="user.executing"
aria-label="Loading user profile"
/>
<!-- Content -->
<div v-if="user.data" role="main">
<UserProfile :user="user.data" />
</div>
</div>
</template>
Focus Management
vue
<script setup lang="ts">
const loadingRef = ref<HTMLElement>()
const contentRef = ref<HTMLElement>()
const user = useQuery(userQuery, {
params: [userId],
onSuccess: () => {
// Focus content when loaded
nextTick(() => {
contentRef.value?.focus()
})
}
})
// Focus loading indicator when loading starts
watch(() => user.executing.value, (executing) => {
if (executing) {
nextTick(() => {
loadingRef.value?.focus()
})
}
})
</script>
<template>
<div>
<div
v-if="user.executing"
ref="loadingRef"
tabindex="-1"
aria-live="polite"
>
<LoadingSpinner /> Loading...
</div>
<div
v-else-if="user.data"
ref="contentRef"
tabindex="-1"
>
<UserProfile :user="user.data" />
</div>
</div>
</template>
Next Steps
- Background Updates - Managing background data refresh
- Error Handling - Handling loading errors
- Optimistic Updates - Improving perceived performance