Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when setting up API clients - TanStack Query, Axios, JWT token management, error handling, or response parsing. NOT when plain fetch calls, non-API data handling, or unrelated UI logic. Triggers: "API client", "data fetching", "JWT token", "error handling", "paginated list", "TanStack Query".
.claude/skills/aiskillstore-api-client/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 64% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 119% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 162% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 163% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 96% | 0% |
Expert guidance for API client implementation using TanStack Query/Axios, including JWT token attachment via interceptors, global error handling with toasts, type-safe response parsing with Zod, and offline detection for robust data fetching.
This skill triggers when users request:
typescript// lib/queryClient.ts import { QueryClient } from '@tanstack/react-query'; export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }, mutations: { retry: 1, }, }, }); // app/layout.tsx or app/providers.tsx 'use client'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '@/lib/queryClient'; export function Providers({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ); }
Requirements:
typescript// lib/apiClient.ts import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import { useAuthStore } from '@/lib/auth-store'; class ApiClient { private client: AxiosInstance; constructor() { this.client = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api', timeout: 10000, // 10 seconds }); this.setupInterceptors(); } private setupInterceptors() { // Request interceptor - attach JWT token this.client.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const { session } = useAuthStore.getState(); if (session?.token && config.headers) { config.headers.Authorization = `Bearer ${session.token}`; } return config; }, (error) => Promise.reject(error) ); // Response interceptor - handle errors and 401 this.client.interceptors.response.use( (response: AxiosResponse) => response, async (error) => { if (error.response?.status === 401) { const { refresh } = useAuthStore.getState(); try { const newToken = await refresh(); if (newToken) { error.config!.headers!.Authorization = `Bearer ${newToken}`; return this.client(error.config!); } } catch (refreshError) { useAuthStore.getState().signOut(); window.location.href = '/auth/login'; } } return Promise.reject(error); } ); } get<T>(url: string, config?: AxiosRequestConfig) { return this.client.get<T>(url, config); } post<T>(url: string, data?: any, config?: AxiosRequestConfig) { return this.client.post<T>(url, data, config); } put<T>(url: string, data?: any, config?: AxiosRequestConfig) { return this.client.put<T>(url, data, config); } delete<T>(url: string, config?: AxiosRequestConfig) { return this.client.delete<T>(url, config); } } export const apiClient = new ApiClient();
Requirements:
typescript// lib/errorHandler.ts import axios from 'axios'; import { toast } from 'sonner'; export const handleApiError = (error: any) => { if (axios.isAxiosError(error)) { const message = error.response?.data?.message || error.message; switch (error.response?.status) { case 400: toast.error('Bad Request', { description: message }); break; case 401: toast.error('Unauthorized', { description: 'Please log in again' }); break; case 403: toast.error('Forbidden', { description: 'You do not have permission' }); break; case 404: toast.error('Not Found', { description: message }); break; case 429: toast.error('Too Many Requests', { description: 'Please try again later' }); break; case 500: toast.error('Server Error', { description: message }); break; default: toast.error('Error', { description: message || 'Something went wrong' }); } } else { toast.error('Network Error', { description: error.message || 'Something went wrong' }); } };
typescript// hooks/useApi.ts import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; import { apiClient } from '@/lib/apiClient'; import { handleApiError } from '@/lib/errorHandler'; import { z } from 'zod'; export function useApi<T>( queryKey: any[], url: string, options?: Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn'> ) { return useQuery({ queryKey, queryFn: async () => { const response = await apiClient.get<T>(url); return response.data; }, ...options, }); } export function useApiMutation<T, V = any>( url: string, options?: Omit<UseMutationOptions<T, V, void>, 'mutationFn'>, schema?: z.ZodSchema<T> ) { return useMutation({ mutationFn: async (variables: V) => { const response = await apiClient.post<T>(url, variables); // Zod validation if schema provided if (schema) { try { const parsed = schema.parse(response.data); return parsed; } catch (error) { if (error instanceof z.ZodError) { toast.error('Validation Error', { description: error.errors[0].message }); throw new Error(`Response validation failed: ${error.errors[0].message}`); } } } return response.data; }, onError: (error) => { options?.onError?.(error); handleApiError(error); }, onSuccess: (data, variables) => { options?.onSuccess?.(data, variables); if (options?.context?.successMessage) { toast.success('Success', { description: options.context.successMessage }); } }, }); }
Requirements:
typescript// lib/api/types.ts import { z } from 'zod'; // Student type with Zod schema export const StudentSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), role: z.enum(['student', 'teacher', 'admin']), classId: z.string().nullable(), createdAt: z.string(), updatedAt: z.string(), }); export type Student = z.infer<typeof StudentSchema>; // Attendance type export const AttendanceSchema = z.object({ id: z.string(), studentId: z.string(), date: z.string(), status: z.enum(['present', 'absent', 'late']), notes: z.string().optional(), }); export type Attendance = z.infer<typeof AttendanceSchema>; // Paginated response type export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) { return z.object({ data: z.array(itemSchema), meta: z.object({ total: z.number(), page: z.number(), pageSize: z.number(), totalPages: z.number(), }), }); } // hooks/useStudents.ts import { useApi } from './useApi'; import { StudentSchema, PaginatedResponseSchema } from '@/lib/api/types'; export function useStudents(page = 1, pageSize = 20) { return useApi( ['students', 'page', page], `/students?page=${page}&pageSize=${pageSize}`, { select: (data) => { const parsed = PaginatedResponseSchema(StudentSchema).parse(data); return parsed; }, } ); } // hooks/useUpdateStudent.ts export function useUpdateStudent() { const queryClient = useQueryClient(); return useApiMutation( (variables: { id: string; data: Partial<Student> }) => `/students/${variables.id}`, { onSuccess: (_, variables) => { // Invalidate and refetch queryClient.invalidateQueries({ queryKey: ['students'] }); queryClient.invalidateQueries({ queryKey: ['student', variables.id] }); }, context: { successMessage: 'Student updated successfully' }, } ); } // hooks/useDeleteStudent.ts export function useDeleteStudent() { const queryClient = useQueryClient(); return useApiMutation( (id: string) => `/students/${id}`, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['students'] }); }, context: { successMessage: 'Student deleted successfully' }, } ); }
typescript// Infinite queries for pagination import { useInfiniteQuery } from '@tanstack/react-query'; import { StudentSchema } from '@/lib/api/types'; export function useInfiniteStudents() { return useInfiniteQuery({ queryKey: ['students', 'infinite'], queryFn: async ({ pageParam = 1 }) => { const response = await apiClient.get(`/students?page=${pageParam}&pageSize=20`); const data = response.data.map((item: any) => StudentSchema.parse(item)); return { data, nextPage: data.length === 20 ? pageParam + 1 : null, }; }, initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.nextPage, }); } // Optimistic updates with rollback export function useUpdateAttendance() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ studentId, date, status }: { studentId: string; date: string; status: string }) => { return apiClient.put(`/attendance/${studentId}/${date}`, { status }); }, onMutate: async ({ studentId, date, status }) => { // Cancel outgoing queries await queryClient.cancelQueries({ queryKey: ['attendance', studentId] }); // Snapshot previous value const previousAttendance = queryClient.getQueryData(['attendance', studentId]); // Optimistically update queryClient.setQueryData(['attendance', studentId], (old: any) => ({ ...old, data: old.data.map((item: any) => item.date === date ? { ...item, status } : item ), })); return { previousAttendance }; }, onError: (error, variables, context) => { // Rollback on error if (context?.previousAttendance) { queryClient.setQueryData(['attendance', variables.studentId], context.previousAttendance); } }, onSettled: (_, __, variables) => { // Refetch on success or error queryClient.invalidateQueries({ queryKey: ['attendance', variables.studentId] }); }, }); } // Offline detection export function useOnlineStatus() { const [isOnline, setIsOnline] = useState(navigator.onLine); useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); return isOnline; } // AbortController for cancelable requests export function useFetchWithAbort<T>(url: string) { const [data, setData] = useState<T | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState(false); const abortControllerRef = useRef<AbortController | null>(null); useEffect(() => { return () => { abortControllerRef.current?.abort(); }; }, []); const fetchData = useCallback(async () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } abortControllerRef.current = new AbortController(); setLoading(true); setError(null); try { const response = await apiClient.get<T>(url, { signal: abortControllerRef.current.signal, }); setData(response.data); } catch (err) { if (err instanceof Error && err.name !== 'AbortError') { setError(err); } } finally { setLoading(false); } }, [url]); return { data, error, loading, refetch: fetchData, abort: () => abortControllerRef.current?.abort() }; }
Requirements:
lib/apiClient.ts - Axios instance with interceptorslib/queryClient.ts - TanStack Query configurationlib/errorHandler.ts - Global error handlerhooks/useApi.ts - Type-safe API hookslib/api/types.ts - Zod schemas and typeshooks/useStudents.ts - Student-specific hookshooks/useAttendance.ts - Attendance-specific hooksBefore completing any API client implementation:
typescript// hooks/useStudent.ts export function useStudent(id: string) { return useApi( ['student', id], `/students/${id}`, { enabled: !!id, // Only fetch if id exists } ); } // Usage function StudentProfile({ studentId }: { studentId: string }) { const { data: student, isLoading, error } = useStudent(studentId); if (isLoading) return <LoadingSkeleton />; if (error) return <ErrorMessage error={error} />; return ( <div> <h1>{student?.name}</h1> <p>{student?.email}</p> </div> ); }
typescript// hooks/useCreateStudent.ts export function useCreateStudent() { const queryClient = useQueryClient(); return useApiMutation( async (data: { name: string; email: string }) => { const response = await apiClient.post('/students', data); // Zod validation const parsed = StudentSchema.parse(response.data); return parsed; }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['students'] }); }, context: { successMessage: 'Student created successfully' }, } ); } // Usage function CreateStudentForm() { const { mutate: createStudent, isPending } = useCreateStudent(); const handleSubmit = (data: FormData) => { createStudent(data); }; return <form onSubmit={handleSubmit}>{/* form fields */}</form>; }
typescript// hooks/useInfiniteStudents.ts export function useInfiniteStudents() { return useInfiniteQuery({ queryKey: ['students', 'infinite'], queryFn: async ({ pageParam = 1 }) => { const response = await apiClient.get(`/students?page=${pageParam}&pageSize=20`); const parsed = z.array(StudentSchema).parse(response.data); return { data: parsed, nextPage: parsed.length === 20 ? pageParam + 1 : null, }; }, initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.nextPage, }); } // Usage function StudentList() { const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteStudents(); return ( <div> {data?.pages.map((page, i) => ( <div key={i}> {page.data.map((student) => ( <StudentCard key={student.id} student={student} /> ))} </div> ))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage} > {isFetchingNextPage ? 'Loading...' : 'Load More'} </button> )} </div> ); }
typescript// hooks/useAttendance.ts export function useAttendance(studentId: string, date: string) { const isOnline = useOnlineStatus(); return useApi( ['attendance', studentId, date], `/attendance/${studentId}/${date}`, { enabled: !!studentId && !!date && isOnline, staleTime: 5 * 60 * 1000, } ); } // Usage function AttendanceCard({ studentId, date }: { studentId: string; date: string }) { const { data: attendance, isLoading, error } = useAttendance(studentId, date); const isOnline = useOnlineStatus(); if (!isOnline) { return <OfflineMessage />; } if (isLoading) return <LoadingSkeleton />; if (error) return <ErrorMessage error={error} />; return ( <div> <p>Status: {attendance?.status}</p> </div> ); }
typescript// lib/queryClient.ts export const queryClient = new QueryClient({ defaultOptions: { queries: { // Fresh data is considered stale after 5 minutes staleTime: 5 * 60 * 1000, // Garbage collect unused queries after 10 minutes gcTime: 10 * 60 * 1000, // Retry failed requests 3 times retry: 3, // Exponential backoff: 1s, 2s, 4s (max 30s) retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Refetch on window focus (optional) refetchOnWindowFocus: false, // Refetch on reconnect refetchOnReconnect: true, }, }, });
bash# .env.local NEXT_PUBLIC_API_URL=http://localhost:3001/api # For production NEXT_PUBLIC_API_URL=https://api.yourapp.com
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 37,646 | 31,751 | -16% | 1 | 1 | 0% | 6,747 | 11,053 | +64% | 0 | 0 | — |
case-02 | fail→pass | 27,584 | 27,805 | +1% | 1 | 1 | 0% | 4,852 | 10,622 | +119% | 0 | 0 | — |
case-03 | fail→pass | 25,016 | 28,078 | +12% | 1 | 1 | 0% | 4,185 | 10,960 | +162% | 0 | 0 | — |
case-04 | pass→pass | 16,638 | 18,144 | +9% | 1 | 1 | 0% | 2,431 | 8,271 | +240% | 0 | 0 | — |
case-05 | pass→pass | 9,385 | 13,791 | +47% | 1 | 1 | 0% | 1,772 | 8,393 | +374% | 0 | 0 | — |
case-11 | fail→pass | 21,840 | 19,869 | -9% | 1 | 1 | 0% | 3,264 | 8,578 | +163% | 0 | 0 | — |
case-06 | pass→pass | 27,364 | 14,043 | -49% | 1 | 1 | 0% | 1,805 | 8,524 | +372% | 0 | 0 | — |
case-07 | fail→pass | 29,926 | 19,879 | -34% | 1 | 1 | 0% | 4,315 | 8,456 | +96% | 0 | 0 | — |
case-08 | pass→pass | 12,078 | 15,298 | +27% | 1 | 1 | 0% | 2,165 | 7,239 | +234% | 0 | 0 | — |
case-09 | pass→fail | 19,992 | 43,989 | +120% | 1 | 1 | 0% | 2,957 | 6,996 | +137% | 0 | 0 | — |
case-10 | fail→pass | 17,675 | 18,165 | +3% | 1 | 1 | 0% | 3,468 | 8,321 | +140% | 0 | 0 | — |
case-12 | fail→pass | 27,120 | 18,378 | -32% | 1 | 1 | 0% | 3,860 | 9,365 | +143% | 0 | 0 | — |
case-13 | pass→pass | 9,718 | 14,884 | +53% | 1 | 1 | 0% | 1,953 | 7,628 | +291% | 0 | 0 | — |
case-14 | pass→pass | 14,349 | 8,693 | -39% | 1 | 1 | 0% | 1,843 | 7,194 | +290% | 0 | 0 | — |
case-15 | fail→fail | 15,809 | 37,245 | +136% | 1 | 1 | 0% | 3,255 | 7,005 | +115% | 0 | 0 | — |
case-16 | fail→fail | 16,332 | 18,006 | +10% | 1 | 1 | 0% | 2,189 | 8,295 | +279% | 0 | 0 | — |
case-17 | fail→pass | 19,498 | 20,358 | +4% | 1 | 1 | 0% | 2,088 | 8,629 | +313% | 0 | 0 | — |
case-18 | pass→pass | 18,400 | 29,414 | +60% | 1 | 1 | 0% | 2,949 | 8,887 | +201% | 0 | 0 | — |
case-19 | pass→pass | 15,570 | 16,538 | +6% | 1 | 1 | 0% | 2,110 | 7,789 | +269% | 0 | 0 | — |
case-20 | pass→pass | 21,285 | 24,413 | +15% | 1 | 1 | 0% | 3,097 | 9,770 | +215% | 0 | 0 | — |
case-21 | pass→pass | 14,176 | 11,371 | -20% | 1 | 1 | 0% | 1,714 | 6,712 | +292% | 0 | 0 | — |
case-22 | pass→pass | 12,021 | 13,241 | +10% | 1 | 1 | 0% | 2,324 | 8,238 | +254% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 20 counted toward the lift figure. The other 2 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +32 percentage points is the difference between those two pass rates over the 20 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.