Skip to content

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 ​

  1. Cache Strategy - Cache GET requests, invalidate on mutations
  2. Error Boundaries - Handle errors gracefully with user feedback
  3. Loading States - Show loading indicators for better UX
  4. Optimistic Updates - Update UI immediately, revert on failure
  5. Request Cancellation - Cancel pending requests when needed
  6. Retry Logic - Implement exponential backoff for failed requests
  7. Offline Handling - Queue mutations when offline

Released under the MIT License.