API Integration Example
This example demonstrates how to integrate Pinia with REST APIs effectively. It includes data fetching, caching strategies, error handling, optimistic updates, and pagination.
Overview
The API integration example showcases:
- RESTful API integration
- Data fetching and caching
- Error handling and retry logic
- Optimistic updates
- Pagination and infinite scrolling
- Request deduplication
- Background data synchronization
- Offline support
- Request/response interceptors
Features
- ✅ GET, POST, PUT, DELETE operations
- ✅ Request caching and invalidation
- ✅ Error handling with retry
- ✅ Loading states management
- ✅ Optimistic updates
- ✅ Pagination support
- ✅ Request deduplication
- ✅ Background refresh
- ✅ Offline queue
- ✅ Request cancellation
- ✅ Response transformation
Type Definitions
ts
// types/api.ts
export interface ApiResponse<T = any> {
data: T
message?: string
success: boolean
timestamp: string
}
export interface PaginatedResponse<T = any> {
data: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
success: boolean
}
export interface ApiError {
message: string
code: string
status: number
details?: Record<string, any>
timestamp: string
}
export interface RequestConfig {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
url: string
data?: any
params?: Record<string, any>
headers?: Record<string, string>
timeout?: number
retries?: number
cache?: boolean
cacheTTL?: number
optimistic?: boolean
background?: boolean
}
export interface CacheEntry<T = any> {
data: T
timestamp: number
ttl: number
key: string
}
export interface LoadingState {
[key: string]: boolean
}
export interface ErrorState {
[key: string]: ApiError | null
}
export interface Post {
id: string
title: string
content: string
author: {
id: string
name: string
avatar?: string
}
tags: string[]
publishedAt: string
updatedAt: string
likes: number
comments: number
status: 'draft' | 'published' | 'archived'
}
export interface CreatePostData {
title: string
content: string
tags: string[]
status: 'draft' | 'published'
}
export interface UpdatePostData extends Partial<CreatePostData> {
id: string
}
export interface PostFilters {
author?: string
tags?: string[]
status?: Post['status']
search?: string
dateFrom?: string
dateTo?: string
}
API Store
ts
// stores/api.ts
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import type {
ApiResponse,
PaginatedResponse,
ApiError,
RequestConfig,
CacheEntry,
LoadingState,
ErrorState,
Post,
CreatePostData,
UpdatePostData,
PostFilters
} from '../types/api'
export const useApiStore = defineStore('api', () => {
// State
const cache = ref<Map<string, CacheEntry>>(new Map())
const loading = ref<LoadingState>({})
const errors = ref<ErrorState>({})
const pendingRequests = ref<Map<string, AbortController>>(new Map())
const offlineQueue = ref<RequestConfig[]>([])
const isOnline = ref(navigator.onLine)
const retryQueue = ref<Array<{ config: RequestConfig; attempt: number }>>([])
// Posts specific state
const posts = ref<Post[]>([])
const currentPost = ref<Post | null>(null)
const postsFilters = ref<PostFilters>({})
const postsPagination = ref({
page: 1,
limit: 10,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false
})
// Computed
const isLoading = computed(() => {
return (key: string) => loading.value[key] || false
})
const getError = computed(() => {
return (key: string) => errors.value[key] || null
})
const filteredPosts = computed(() => {
let filtered = posts.value
if (postsFilters.value.author) {
filtered = filtered.filter(post =>
post.author.id === postsFilters.value.author
)
}
if (postsFilters.value.tags?.length) {
filtered = filtered.filter(post =>
postsFilters.value.tags!.some(tag => post.tags.includes(tag))
)
}
if (postsFilters.value.status) {
filtered = filtered.filter(post =>
post.status === postsFilters.value.status
)
}
if (postsFilters.value.search) {
const search = postsFilters.value.search.toLowerCase()
filtered = filtered.filter(post =>
post.title.toLowerCase().includes(search) ||
post.content.toLowerCase().includes(search)
)
}
return filtered
})
// Utility functions
const generateCacheKey = (config: RequestConfig): string => {
const { method, url, params, data } = config
return `${method}:${url}:${JSON.stringify(params || {})}:${JSON.stringify(data || {})}`
}
const isCacheValid = (entry: CacheEntry): boolean => {
return Date.now() - entry.timestamp < entry.ttl
}
const setLoading = (key: string, value: boolean) => {
loading.value[key] = value
}
const setError = (key: string, error: ApiError | null) => {
errors.value[key] = error
}
const clearError = (key: string) => {
delete errors.value[key]
}
// Core API methods
const request = async <T = any>(config: RequestConfig): Promise<T> => {
const cacheKey = generateCacheKey(config)
const loadingKey = `${config.method}:${config.url}`
// Check cache for GET requests
if (config.method === 'GET' && config.cache !== false) {
const cached = cache.value.get(cacheKey)
if (cached && isCacheValid(cached)) {
return cached.data
}
}
// Check for duplicate requests
if (pendingRequests.value.has(cacheKey)) {
// Wait for existing request
return new Promise((resolve, reject) => {
const checkRequest = () => {
if (!pendingRequests.value.has(cacheKey)) {
const cached = cache.value.get(cacheKey)
if (cached) {
resolve(cached.data)
} else {
reject(new Error('Request failed'))
}
} else {
setTimeout(checkRequest, 100)
}
}
checkRequest()
})
}
// Handle offline requests
if (!isOnline.value && config.method !== 'GET') {
offlineQueue.value.push(config)
throw new Error('Request queued for when online')
}
setLoading(loadingKey, true)
clearError(loadingKey)
// Create abort controller
const abortController = new AbortController()
pendingRequests.value.set(cacheKey, abortController)
try {
const url = new URL(config.url, window.location.origin)
// Add query parameters
if (config.params) {
Object.entries(config.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value))
}
})
}
const requestInit: RequestInit = {
method: config.method,
headers: {
'Content-Type': 'application/json',
...config.headers
},
signal: abortController.signal
}
if (config.data && config.method !== 'GET') {
requestInit.body = JSON.stringify(config.data)
}
const response = await fetch(url.toString(), requestInit)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const apiError: ApiError = {
message: errorData.message || `HTTP ${response.status}`,
code: errorData.code || 'HTTP_ERROR',
status: response.status,
details: errorData.details,
timestamp: new Date().toISOString()
}
throw apiError
}
const data = await response.json()
// Cache successful GET requests
if (config.method === 'GET' && config.cache !== false) {
const ttl = config.cacheTTL || 5 * 60 * 1000 // 5 minutes default
cache.value.set(cacheKey, {
data,
timestamp: Date.now(),
ttl,
key: cacheKey
})
}
return data
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Request cancelled')
}
const apiError = error as ApiError
setError(loadingKey, apiError)
// Retry logic
if (config.retries && config.retries > 0) {
const retryConfig = { ...config, retries: config.retries - 1 }
retryQueue.value.push({ config: retryConfig, attempt: 1 })
// Retry after delay
setTimeout(() => {
processRetryQueue()
}, 1000 * Math.pow(2, 1)) // Exponential backoff
}
throw apiError
} finally {
setLoading(loadingKey, false)
pendingRequests.value.delete(cacheKey)
}
}
const processRetryQueue = async () => {
const retries = [...retryQueue.value]
retryQueue.value = []
for (const { config, attempt } of retries) {
try {
await request(config)
} catch (error) {
if (config.retries && config.retries > 0) {
retryQueue.value.push({
config: { ...config, retries: config.retries - 1 },
attempt: attempt + 1
})
}
}
}
}
const get = <T = any>(url: string, params?: Record<string, any>, options?: Partial<RequestConfig>): Promise<T> => {
return request<T>({
method: 'GET',
url,
params,
cache: true,
...options
})
}
const post = <T = any>(url: string, data?: any, options?: Partial<RequestConfig>): Promise<T> => {
return request<T>({
method: 'POST',
url,
data,
...options
})
}
const put = <T = any>(url: string, data?: any, options?: Partial<RequestConfig>): Promise<T> => {
return request<T>({
method: 'PUT',
url,
data,
...options
})
}
const del = <T = any>(url: string, options?: Partial<RequestConfig>): Promise<T> => {
return request<T>({
method: 'DELETE',
url,
...options
})
}
// Posts API methods
const fetchPosts = async (page = 1, limit = 10, filters?: PostFilters) => {
try {
const params = {
page,
limit,
...filters
}
const response = await get<PaginatedResponse<Post>>('/api/posts', params)
if (page === 1) {
posts.value = response.data
} else {
posts.value.push(...response.data)
}
postsPagination.value = response.pagination
postsFilters.value = filters || {}
return response
} catch (error) {
console.error('Failed to fetch posts:', error)
throw error
}
}
const fetchPost = async (id: string) => {
try {
const response = await get<ApiResponse<Post>>(`/api/posts/${id}`)
currentPost.value = response.data
// Update post in list if it exists
const index = posts.value.findIndex(post => post.id === id)
if (index !== -1) {
posts.value[index] = response.data
}
return response.data
} catch (error) {
console.error('Failed to fetch post:', error)
throw error
}
}
const createPost = async (data: CreatePostData) => {
try {
// Optimistic update
const tempPost: Post = {
id: `temp-${Date.now()}`,
...data,
author: { id: 'current-user', name: 'You' },
publishedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
likes: 0,
comments: 0
}
posts.value.unshift(tempPost)
const response = await post<ApiResponse<Post>>('/api/posts', data, {
optimistic: true
})
// Replace temp post with real post
const tempIndex = posts.value.findIndex(post => post.id === tempPost.id)
if (tempIndex !== -1) {
posts.value[tempIndex] = response.data
}
// Invalidate cache
invalidateCache('/api/posts')
return response.data
} catch (error) {
// Revert optimistic update
const tempIndex = posts.value.findIndex(post => post.id.startsWith('temp-'))
if (tempIndex !== -1) {
posts.value.splice(tempIndex, 1)
}
console.error('Failed to create post:', error)
throw error
}
}
const updatePost = async (data: UpdatePostData) => {
try {
// Optimistic update
const index = posts.value.findIndex(post => post.id === data.id)
let originalPost: Post | null = null
if (index !== -1) {
originalPost = { ...posts.value[index] }
posts.value[index] = { ...posts.value[index], ...data, updatedAt: new Date().toISOString() }
}
const response = await put<ApiResponse<Post>>(`/api/posts/${data.id}`, data, {
optimistic: true
})
// Update with server response
if (index !== -1) {
posts.value[index] = response.data
}
if (currentPost.value?.id === data.id) {
currentPost.value = response.data
}
// Invalidate cache
invalidateCache(`/api/posts/${data.id}`)
invalidateCache('/api/posts')
return response.data
} catch (error) {
// Revert optimistic update
if (originalPost && index !== -1) {
posts.value[index] = originalPost
}
console.error('Failed to update post:', error)
throw error
}
}
const deletePost = async (id: string) => {
try {
// Optimistic update
const index = posts.value.findIndex(post => post.id === id)
let removedPost: Post | null = null
if (index !== -1) {
removedPost = posts.value[index]
posts.value.splice(index, 1)
}
await del(`/api/posts/${id}`, { optimistic: true })
if (currentPost.value?.id === id) {
currentPost.value = null
}
// Invalidate cache
invalidateCache(`/api/posts/${id}`)
invalidateCache('/api/posts')
} catch (error) {
// Revert optimistic update
if (removedPost && index !== -1) {
posts.value.splice(index, 0, removedPost)
}
console.error('Failed to delete post:', error)
throw error
}
}
const likePost = async (id: string) => {
try {
// Optimistic update
const index = posts.value.findIndex(post => post.id === id)
if (index !== -1) {
posts.value[index].likes++
}
if (currentPost.value?.id === id) {
currentPost.value.likes++
}
await post(`/api/posts/${id}/like`, {}, { optimistic: true })
} catch (error) {
// Revert optimistic update
if (index !== -1) {
posts.value[index].likes--
}
if (currentPost.value?.id === id) {
currentPost.value.likes--
}
console.error('Failed to like post:', error)
throw error
}
}
// Cache management
const invalidateCache = (pattern?: string) => {
if (!pattern) {
cache.value.clear()
return
}
for (const [key] of cache.value) {
if (key.includes(pattern)) {
cache.value.delete(key)
}
}
}
const clearCache = () => {
cache.value.clear()
}
const cancelRequest = (url: string, method = 'GET') => {
const pattern = `${method}:${url}`
for (const [key, controller] of pendingRequests.value) {
if (key.startsWith(pattern)) {
controller.abort()
pendingRequests.value.delete(key)
}
}
}
const cancelAllRequests = () => {
for (const [key, controller] of pendingRequests.value) {
controller.abort()
}
pendingRequests.value.clear()
}
// Background sync
const syncOfflineQueue = async () => {
if (!isOnline.value || offlineQueue.value.length === 0) return
const queue = [...offlineQueue.value]
offlineQueue.value = []
for (const config of queue) {
try {
await request(config)
} catch (error) {
console.error('Failed to sync offline request:', error)
// Re-queue failed requests
offlineQueue.value.push(config)
}
}
}
const refreshData = async () => {
// Clear cache and refetch current data
clearCache()
if (posts.value.length > 0) {
await fetchPosts(1, postsPagination.value.limit, postsFilters.value)
}
if (currentPost.value) {
await fetchPost(currentPost.value.id)
}
}
// Setup online/offline listeners
const setupNetworkListeners = () => {
const handleOnline = () => {
isOnline.value = true
syncOfflineQueue()
}
const handleOffline = () => {
isOnline.value = false
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
// Cleanup function
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}
// Auto-refresh data periodically
const setupAutoRefresh = (interval = 5 * 60 * 1000) => { // 5 minutes
const timer = setInterval(() => {
if (isOnline.value && document.visibilityState === 'visible') {
refreshData()
}
}, interval)
return () => clearInterval(timer)
}
// Initialize
const cleanup = setupNetworkListeners()
const autoRefreshCleanup = setupAutoRefresh()
return {
// State
posts: readonly(posts),
currentPost: readonly(currentPost),
postsFilters: readonly(postsFilters),
postsPagination: readonly(postsPagination),
isOnline: readonly(isOnline),
offlineQueue: readonly(offlineQueue),
// Computed
isLoading,
getError,
filteredPosts,
// Core API methods
request,
get,
post,
put,
del,
// Posts methods
fetchPosts,
fetchPost,
createPost,
updatePost,
deletePost,
likePost,
// Cache management
invalidateCache,
clearCache,
// Request management
cancelRequest,
cancelAllRequests,
// Sync methods
syncOfflineQueue,
refreshData,
// Cleanup
cleanup: () => {
cleanup()
autoRefreshCleanup()
cancelAllRequests()
}
}
})
Component Usage
Posts List Component
vue
<!-- components/PostsList.vue -->
<template>
<div class="posts-list">
<div class="posts-header">
<h2>Posts</h2>
<button @click="refreshPosts" :disabled="apiStore.isLoading('GET:/api/posts')">
{{ apiStore.isLoading('GET:/api/posts') ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
<div class="posts-filters">
<input
v-model="searchQuery"
type="text"
placeholder="Search posts..."
@input="debouncedSearch"
/>
<select v-model="statusFilter" @change="applyFilters">
<option value="">All Status</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>
</div>
<div v-if="apiStore.getError('GET:/api/posts')" class="error">
{{ apiStore.getError('GET:/api/posts')?.message }}
<button @click="retryFetch">Retry</button>
</div>
<div v-if="apiStore.isLoading('GET:/api/posts') && !apiStore.posts.length" class="loading">
Loading posts...
</div>
<div class="posts-grid">
<div
v-for="post in apiStore.filteredPosts"
:key="post.id"
class="post-card"
:class="{ 'optimistic': post.id.startsWith('temp-') }"
>
<h3>{{ post.title }}</h3>
<p>{{ post.content.substring(0, 150) }}...</p>
<div class="post-meta">
<span>By {{ post.author.name }}</span>
<span>{{ formatDate(post.publishedAt) }}</span>
<span class="status" :class="post.status">{{ post.status }}</span>
</div>
<div class="post-actions">
<button @click="likePost(post.id)" :disabled="post.id.startsWith('temp-')">
❤️ {{ post.likes }}
</button>
<button @click="editPost(post)" :disabled="post.id.startsWith('temp-')">
Edit
</button>
<button
@click="deletePost(post.id)"
:disabled="post.id.startsWith('temp-')"
class="danger"
>
Delete
</button>
</div>
</div>
</div>
<div v-if="apiStore.postsPagination.hasNext" class="pagination">
<button
@click="loadMore"
:disabled="apiStore.isLoading('GET:/api/posts')"
>
{{ apiStore.isLoading('GET:/api/posts') ? 'Loading...' : 'Load More' }}
</button>
</div>
<div v-if="!apiStore.isOnline" class="offline-notice">
You're offline. Changes will be synced when connection is restored.
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useApiStore } from '../stores/api'
import type { Post } from '../types/api'
const apiStore = useApiStore()
const searchQuery = ref('')
const statusFilter = ref('')
let searchTimeout: NodeJS.Timeout
const debouncedSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
applyFilters()
}, 300)
}
const applyFilters = async () => {
const filters = {
search: searchQuery.value || undefined,
status: statusFilter.value || undefined
}
await apiStore.fetchPosts(1, 10, filters)
}
const refreshPosts = async () => {
await apiStore.refreshData()
}
const retryFetch = async () => {
await apiStore.fetchPosts(1, 10, apiStore.postsFilters)
}
const loadMore = async () => {
const nextPage = apiStore.postsPagination.page + 1
await apiStore.fetchPosts(nextPage, 10, apiStore.postsFilters)
}
const likePost = async (id: string) => {
try {
await apiStore.likePost(id)
} catch (error) {
console.error('Failed to like post:', error)
}
}
const editPost = (post: Post) => {
// Navigate to edit page or open modal
console.log('Edit post:', post)
}
const deletePost = async (id: string) => {
if (confirm('Are you sure you want to delete this post?')) {
try {
await apiStore.deletePost(id)
} catch (error) {
console.error('Failed to delete post:', error)
}
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString()
}
onMounted(() => {
apiStore.fetchPosts()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
})
</script>
Create Post Component
vue
<!-- components/CreatePost.vue -->
<template>
<form @submit.prevent="handleSubmit" class="create-post-form">
<div class="form-group">
<label for="title">Title</label>
<input
id="title"
v-model="form.title"
type="text"
required
:disabled="apiStore.isLoading('POST:/api/posts')"
/>
</div>
<div class="form-group">
<label for="content">Content</label>
<textarea
id="content"
v-model="form.content"
rows="10"
required
:disabled="apiStore.isLoading('POST:/api/posts')"
></textarea>
</div>
<div class="form-group">
<label for="tags">Tags</label>
<input
id="tags"
v-model="tagsInput"
type="text"
placeholder="Enter tags separated by commas"
:disabled="apiStore.isLoading('POST:/api/posts')"
/>
</div>
<div class="form-group">
<label for="status">Status</label>
<select
id="status"
v-model="form.status"
:disabled="apiStore.isLoading('POST:/api/posts')"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
<div v-if="apiStore.getError('POST:/api/posts')" class="error">
{{ apiStore.getError('POST:/api/posts')?.message }}
</div>
<div class="form-actions">
<button
type="submit"
:disabled="apiStore.isLoading('POST:/api/posts')"
>
{{ apiStore.isLoading('POST:/api/posts') ? 'Creating...' : 'Create Post' }}
</button>
<button type="button" @click="resetForm">
Reset
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useApiStore } from '../stores/api'
import type { CreatePostData } from '../types/api'
const emit = defineEmits<{
created: [post: any]
}>()
const apiStore = useApiStore()
const form = ref<CreatePostData>({
title: '',
content: '',
tags: [],
status: 'draft'
})
const tagsInput = ref('')
const handleSubmit = async () => {
try {
// Parse tags
form.value.tags = tagsInput.value
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0)
const post = await apiStore.createPost(form.value)
emit('created', post)
resetForm()
} catch (error) {
console.error('Failed to create post:', error)
}
}
const resetForm = () => {
form.value = {
title: '',
content: '',
tags: [],
status: 'draft'
}
tagsInput.value = ''
}
</script>
Testing
ts
// tests/api.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useApiStore } from '../stores/api'
// Mock fetch
global.fetch = vi.fn()
describe('API Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('fetches posts successfully', async () => {
const apiStore = useApiStore()
const mockResponse = {
data: [
{
id: '1',
title: 'Test Post',
content: 'Test content',
author: { id: '1', name: 'Test Author' },
tags: ['test'],
publishedAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
likes: 0,
comments: 0,
status: 'published'
}
],
pagination: {
page: 1,
limit: 10,
total: 1,
totalPages: 1,
hasNext: false,
hasPrev: false
},
success: true
}
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse)
} as Response)
await apiStore.fetchPosts()
expect(apiStore.posts).toHaveLength(1)
expect(apiStore.posts[0].title).toBe('Test Post')
})
it('handles API errors', async () => {
const apiStore = useApiStore()
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.resolve({ message: 'Server Error' })
} as Response)
await expect(apiStore.fetchPosts()).rejects.toThrow('Server Error')
expect(apiStore.getError('GET:/api/posts')?.message).toBe('Server Error')
})
it('performs optimistic updates', async () => {
const apiStore = useApiStore()
// Set initial posts
apiStore.posts = [{
id: '1',
title: 'Original Title',
content: 'Original content',
author: { id: '1', name: 'Author' },
tags: [],
publishedAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
likes: 5,
comments: 0,
status: 'published'
}]
// Mock successful like request
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true })
} as Response)
await apiStore.likePost('1')
expect(apiStore.posts[0].likes).toBe(6)
})
it('caches GET requests', async () => {
const apiStore = useApiStore()
const mockData = { data: 'test' }
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockData)
} as Response)
// First request
const result1 = await apiStore.get('/api/test')
expect(result1).toEqual(mockData)
expect(fetch).toHaveBeenCalledTimes(1)
// Second request should use cache
const result2 = await apiStore.get('/api/test')
expect(result2).toEqual(mockData)
expect(fetch).toHaveBeenCalledTimes(1) // Still only called once
})
})
Key Concepts
1. Request Caching
Automatic caching of GET requests with configurable TTL.
2. Optimistic Updates
Immediate UI updates with rollback on failure.
3. Error Handling
Comprehensive error handling with retry logic.
4. Request Deduplication
Prevents duplicate requests for the same resource.
5. Offline Support
Queues requests when offline and syncs when online.
Best Practices
- Cache Strategy - Cache GET requests, invalidate on mutations
- Error Boundaries - Handle errors gracefully with user feedback
- Loading States - Show loading indicators for better UX
- Optimistic Updates - Update UI immediately, revert on failure
- Request Cancellation - Cancel pending requests when needed
- Retry Logic - Implement exponential backoff for failed requests
- Offline Handling - Queue mutations when offline