SuperAPI AI Directory

data-fetching

Data & analytics

View repository

Updated 10-16 08:49

About this skill

Data Fetching

References: TanStack Query official, SWR official, Stripe / Vercel data fetching practices, stale-while-revalidate RFC 5861.

Use Cases

  • REST / GraphQL API integration
  • Cache strategy design
  • Loading / Error / Empty state management
  • Optimistic Updates
  • Infinite scroll / pagination
  • Real-time data synchronization
  • Offline-first

Core Principles

1. 服务端状态用专门库
   不要 useEffect + useState 手撕
  1. SWR pattern (Stale-While-Revalidate) Use cache first (even if stale) → refetch in the background

  2. queryKey design is the core Hierarchical: ['users', { filter, page }]

  3. staleTime + gcTime two layers staleTime: how long it is "fresh" gcTime: how long until it is removed from cache

  4. Optimistic updates improve experience Update UI immediately → roll back on failure

  5. Error boundary + retry Automatically retry network errors Do not retry business errors

  6. Prefetch On hover / before route change

  7. Do not rewrite fetch on every page Encapsulate an API client


## TanStack Query(推荐 React)

### 基础查询

```typescript
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';

// 单个查询
function OrderDetail({ id }: { id: number }) {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['orders', id],
    queryFn: () => api.getOrder(id),
    staleTime: 5 * 60 * 1000,         // 5 分钟内不重请求
    gcTime: 30 * 60 * 1000,            // 30 分钟后清缓存
    enabled: id > 0,                   // 条件查询
    retry: (failureCount, error) => {
      if (error.status === 404) return false;  // 不存在不重试
      return failureCount < 3;
    },
  });
  
  if (isLoading) return <Skeleton />;
  if (error) return <ErrorState onRetry={refetch} />;
  if (!data) return <Empty />;
  return <Detail order={data} />;
}

List + Pagination

function OrderList() {
  const [page, setPage] = useState(1);
  const [filter, setFilter] = useState({ status: 'all' });
  
  const { data, isLoading, isFetching } = useQuery({
    queryKey: ['orders', { page, filter }],   // 参数变化自动新查询
    queryFn: () => api.getOrders({ page, ...filter }),
    placeholderData: keepPreviousData,         // 切换页时保留旧数据(防闪烁)
  });
  
  return (
    <>
      {isFetching && <RefreshIndicator />}
      <List items={data?.items ?? []} />
      <Pagination page={page} onChange={setPage} total={data?.total ?? 0} />
    </>
  );
}

Infinite Scroll

import { useInfiniteQuery } from '@tanstack/react-query';

function InfiniteOrderList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['orders', 'infinite'],
    queryFn: ({ pageParam = 0 }) => api.getOrders({ cursor: pageParam }),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    initialPageParam: 0,
  });
  
  const allOrders = data?.pages.flatMap(p => p.items) ?? [];
  
  return (
    <>
      {allOrders.map(order => <OrderCard key={order.id} order={order} />)}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? '加载中...' : '加载更多'}
        </button>
      )}
    </>
  );
}

// 配合 Intersection Observer 自动加载
function useIntersectionFetchNext(ref, fetchNextPage, hasNextPage) {
  useEffect(() => {
    if (!ref.current || !hasNextPage) return;
    const observer = new IntersectionObserver(
      ([entry]) => entry.isIntersecting && fetchNextPage(),
      { threshold: 0.1 }
    );
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [ref, fetchNextPage, hasNextPage]);
}

Mutation

function CreateOrderForm() {
  const qc = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: api.createOrder,
    onSuccess: (newOrder) => {
      // 失效列表查询(自动重新请求)
      qc.invalidateQueries({ queryKey: ['orders'] });
      // 或:直接更新缓存
      qc.setQueryData(['orders', newOrder.id], newOrder);
      toast.success('订单创建成功');
    },
    onError: (error) => {
      if (error.code === 'INSUFFICIENT_STOCK') {
        toast.error('库存不足');
      } else {
        toast.error('创建失败');
      }
    },
  });
  
  return (
    <form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
      ...
      <button disabled={mutation.isPending}>
        {mutation.isPending ? '创建中...' : '创建'}
      </button>
    </form>
  );
}

Optimistic Updates

const mutation = useMutation({
  mutationFn: api.updateOrder,
  onMutate: async (newOrder) => {
    // 1. 取消进行中的请求(防覆盖)
    await qc.cancelQueries({ queryKey: ['orders', newOrder.id] });
    
    // 2. 保存当前数据用于回滚
    const previous = qc.getQueryData(['orders', newOrder.id]);
    
    // 3. 立即更新 UI
    qc.setQueryData(['orders', newOrder.id], newOrder);
    
    return { previous };
  },
  onError: (err, newOrder, context) => {
    // 失败回滚
    qc.setQueryData(['orders', newOrder.id], context?.previous);
    toast.error('更新失败');
  },
  onSettled: (data, error, variables) => {
    // 不管成败,最终同步
    qc.invalidateQueries({ queryKey: ['orders', variables.id] });
  },
});

Prefetch

// 悬停预加载
function OrderCard({ order }: { order: Order }) {
  const qc = useQueryClient();
  
  const prefetch = () => {
    qc.prefetchQuery({
      queryKey: ['orders', order.id],
      queryFn: () => api.getOrder(order.id),
      staleTime: 60 * 1000,
    });
  };
  
  return (
    <Link to={`/orders/${order.id}`} onMouseEnter={prefetch} onFocus={prefetch}>
      {order.title}
    </Link>
  );
}

// 路由切换前预加载(React Router loaders)
const router = createBrowserRouter([
  {
    path: '/orders/:id',
    loader: ({ params }) => qc.prefetchQuery({
      queryKey: ['orders', params.id],
      queryFn: () => api.getOrder(params.id),
    }),
  },
]);

Global Configuration

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,                    // 默认 1 分钟新鲜
      gcTime: 5 * 60 * 1000,                    // 5 分钟清缓存
      refetchOnWindowFocus: false,              // 关注口取消(看业务)
      retry: 3,
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
    },
    mutations: {
      retry: 0,
    },
  },
});

<QueryClientProvider client={queryClient}>
  <App />
  {process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}
</QueryClientProvider>

SWR (Lightweight Alternative)

import useSWR, { mutate } from 'swr';

function useOrder(id: number) {
  const { data, error, isLoading } = useSWR(
    id > 0 ? `/api/orders/${id}` : null,
    fetcher,
    {
      revalidateOnFocus: true,
      dedupingInterval: 60000,
      errorRetryCount: 3,
    }
  );
  
  return { order: data, isLoading, error };
}

// Mutation
async function updateOrder(id: number, data: Partial<Order>) {
  await api.updateOrder(id, data);
  mutate(`/api/orders/${id}`);  // 刷新缓存
}

Vue 3 + TanStack Query

<script setup lang="ts">
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query';
import { ref } from 'vue';

const filter = ref({ status: 'all' });

const { data: orders, isLoading, error } = useQuery({
  queryKey: ['orders', filter],
  queryFn: () => api.getOrders(filter.value),
});

const qc = useQueryClient();
const createMutation = useMutation({
  mutationFn: api.createOrder,
  onSuccess: () => qc.invalidateQueries({ queryKey: ['orders'] }),
});
</script>

<template>
  <Skeleton v-if="isLoading" />
  <ErrorState v-else-if="error" />
  <OrderList v-else :orders="orders" />
</template>

Vue 3 + Pinia Colada (Emerging)

// 类似 TanStack Query 但与 Pinia 集成
import { useQuery, useMutation } from '@pinia/colada';

const { data, status, error } = useQuery({
  key: ['orders', filter],
  query: () => api.getOrders(filter.value),
});

RTK Query (Already Using Redux)

// api/orderApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const orderApi = createApi({
  reducerPath: 'orderApi',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api/v1',
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token;
      if (token) headers.set('Authorization', `Bearer ${token}`);
      return headers;
    },
  }),
  tagTypes: ['Order'],
  endpoints: (build) => ({
    getOrder: build.query<Order, number>({
      query: (id) => `/orders/${id}`,
      providesTags: (result, error, id) => [{ type: 'Order', id }],
    }),
    createOrder: build.mutation<Order, CreateOrderInput>({
      query: (body) => ({ url: '/orders', method: 'POST', body }),
      invalidatesTags: [{ type: 'Order', id: 'LIST' }],
    }),
  }),
});

export const { useGetOrderQuery, useCreateOrderMutation } = orderApi;

Apollo Client(GraphQL)

import { useQuery, useMutation, gql } from '@apollo/client';

const GET_ORDER = gql`
  query GetOrder($id: ID!) {
    order(id: $id) {
      id
      status
      total
      items { id name quantity }
    }
  }
`;

const { data, loading, error } = useQuery(GET_ORDER, {
  variables: { id },
  fetchPolicy: 'cache-first',
  pollInterval: 30000,  // 30s 轮询
});

API Client Encapsulation

// api/client.ts
import axios, { AxiosError } from 'axios';

const client = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' },
});

// 请求拦截器:注入 Token
client.interceptors.request.use((config) => {
  const token = getToken();
  if (token) config.headers.Authorization = `Bearer ${token}`;
  
  config.headers['X-Request-Id'] = crypto.randomUUID();
  return config;
});

// 响应拦截器:错误统一处理
client.interceptors.response.use(
  (res) => res.data,
  (error: AxiosError<{ error?: { code: string; message: string } }>) => {
    if (error.response?.status === 401) {
      // Token 过期,刷新或跳登录
      return refreshToken().then(() => client(error.config!));
    }
    
    // 包装为业务错误
    const apiError = new ApiError(
      error.response?.data?.error?.code ?? 'NETWORK_ERROR',
      error.response?.data?.error?.message ?? error.message,
      error.response?.status,
    );
    return Promise.reject(apiError);
  }
);

// API 函数
export const api = {
  getOrder: (id: number) => client.get<Order>(`/orders/${id}`),
  createOrder: (data: CreateOrderInput) => client.post<Order>('/orders', data),
  updateOrder: (id: number, data: Partial<Order>) => client.patch<Order>(`/orders/${id}`, data),
  deleteOrder: (id: number) => client.delete(`/orders/${id}`),
};

Mock Service Worker(MSW)

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/v1/orders', ({ request }) => {
    const url = new URL(request.url);
    const status = url.searchParams.get('status');
    
    return HttpResponse.json({
      items: mockOrders.filter(o => !status || o.status === status),
      total: mockOrders.length,
    });
  }),
  
  http.post('/api/v1/orders', async ({ request }) => {
    const body = await request.json() as CreateOrderInput;
    const newOrder = { id: Date.now(), ...body, status: 'DRAFT' };
    return HttpResponse.json(newOrder, { status: 201 });
  }),
];

// 启用(dev / test)
import { worker } from './mocks/browser';
if (import.meta.env.DEV) {
  worker.start();
}

queryKey Design Specification

原则:层次化 + 唯一识别 + 可失效

✅ 推荐:
  ['orders']                          # 所有订单
  ['orders', { filter, page }]        # 筛选 + 分页
  ['orders', orderId]                 # 单个详情
  ['orders', orderId, 'items']        # 关联数据
  ['users', userId, 'orders']         # 嵌套

❌ 反例:
  `orders-${userId}-${page}`          # 字符串拼接
  ['orders' + userId]                  # 不易匹配 invalidate
  
失效策略:
  qc.invalidateQueries({ queryKey: ['orders'] })          # 失效所有 orders 开头
  qc.invalidateQueries({ queryKey: ['orders', orderId] })  # 仅失效该订单

Loading State Best Practices

4 种状态都要处理:
  isLoading        - 首次加载(无任何数据)
  isFetching       - 任何时候在请求(含后台刷新)
  isError          - 出错
  isSuccess        - 成功

UI 渲染:
  if (isLoading) → 骨架屏
  if (isError) → 错误页 + 重试按钮
  if (data && data.length === 0) → 空状态
  if (data) → 内容(同时 isFetching 时角标提示)

Workflow

1. 选择数据获取库
   - REST → TanStack Query / SWR
   - GraphQL → Apollo / urql
   - Redux 已用 → RTK Query

2. 封装 API 客户端
   - 统一拦截 / Token / 错误
   - 类型定义(OpenAPI 生成最佳)

3. queryKey 设计
   - 与业务实体对应

4. 状态处理
   - Loading / Error / Empty / Success

5. 缓存策略
   - staleTime / gcTime
   - invalidate 时机

6. 乐观更新(关键操作)

7. Prefetch(路由 + hover)

8. Mock(MSW 开发环境)

9. 测试
   - 模拟成功 / 失败 / 慢网络

Supporting Templates

  • templates/data-fetching-checklist.md — API client + cache strategy + Loading/Error handling + Mock + tests

Quality Self-Check

□ 用专门库(TanStack Query / SWR)
□ API 客户端封装(拦截 / Token)
□ queryKey 层次化
□ staleTime / gcTime 配置
□ Loading / Error / Empty / Success 全状态
□ 错误统一处理 + 用户友好
□ 重试策略(404 / 403 不重试)
□ 乐观更新(关键操作)
□ Prefetch(路由 / hover)
□ keepPreviousData 切页防闪
□ Mock 覆盖(MSW 开发 + 测试)
□ 不在 useEffect 手撕 fetch
□ 不缓存敏感数据

Common Pitfalls

  1. Hand-rolling useEffect + fetch——duplicate requests / race conditions / no cache
  2. queryKey string concatenation——hard to invalidate
  3. Not distinguishing isLoading / isFetching——full-screen loading on page change
  4. Not handling error boundaries——white screen
  5. Not handling empty——blank when an empty array is returned
  6. Not using keepPreviousData——flicker on page change
  7. Optimistic updates without rollback——UI inconsistency on failure
  8. Putting large amounts of data in Redux——should use TanStack Query
  9. Not invalidating after Mutation——list does not update
  10. Forgetting to cancel polling——still requesting after leaving the page
  11. Not setting a timeout——hangs on slow networks
  12. Technicalizing error messages——users cannot understand them
  13. Caching sensitive data in localStorage——XSS risk
  14. GraphQL over-fetching——poor performance

Collaboration with Other Skills

上游:
  api-designer 工作流 → OpenAPI 契约
  state-management → 服务端状态归这里

下游:
  forms-validation → mutation 提交
  routing-navigation → loader 数据
  performance-optimization → 缓存策略
  testing-frontend → MSW Mock

Related References

  • Project root references/frontend-tech-stack-guide.md — data fetching libraries