expo-data-fetching

作者: expo

Framework (OSS). Use when implementing or debugging ANY network request, API call, or data fetching. Covers fetch API, React Query, SWR, error handling, caching, offline support, and Expo Router data loaders (`useLoaderData`).

npx skills add https://github.com/expo/skills --skill expo-data-fetching

Expo Networking

You MUST use this skill for ANY networking work including API requests, data fetching, caching, or network debugging.

References

Consult these resources as needed:

references/
  expo-router-loaders.md        Route-level data loading with Expo Router loaders (web, SDK 55+)
  offline-and-cancellation.md   NetInfo network status, offline-first React Query, AbortController

When to Use

Use this skill when:

  • Implementing API requests
  • Setting up data fetching (React Query, SWR)
  • Using Expo Router data loaders (useLoaderData, web SDK 55+)
  • Debugging network failures
  • Implementing caching strategies
  • Handling offline scenarios
  • Authentication/token management
  • Configuring API URLs and environment variables

Preferences

  • Avoid axios, prefer expo/fetch

Every Screen Has Four States

Design loading, error, empty, and content for screens that load data. These can overlap: a refresh error should coexist with cached content.

  • Loading ≠ empty. Empty means resolved with zero items, not missing data. Handle initial loading, failure, and hydration before checking list length. In TanStack Query v5, isLoading means the first fetch is running; a disabled or offline-paused query can have no data without being loading. Show the prerequisite or offline state in that case.
  • Empty is a designed state, not a blank list. Use ListEmptyComponent on FlatList/FlashList: explain why it is empty and offer the relevant next action. "No items yet" can offer Create; "No results" should offer changing or clearing the search/filter.
  • Refetches keep stale content. Render cached data even if a refresh fails, with a nonblocking error and retry. Use isLoading for first-fetch spinners and isFetching for background activity; prefer a skeleton for a slow initial load with a known layout, and RefreshControl for user-initiated refresh.
  • Gate on hydration. When initial UI or a redirect depends on persisted state (auth token, onboarding flag), the root layout renders nothing - or the splash - until that state has loaded. Deciding on unhydrated state flashes the wrong screen on every cold start and misroutes deep links that arrive before hydration.

Saves preserve work. While a mutation is pending, disable repeat submission. On failure, retain the draft, show an inline error, and let the user retry; clear or dismiss only after success. If updating optimistically, restore the previous value or mark the edit as unsynced on failure. Verify with a failed save followed by retry.

Common Issues & Solutions

1. Basic Fetch Usage

Simple GET request:

const fetchUser = async (userId: string) => {
  const response = await fetch(`https://api.example.com/users/${userId}`);

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  return response.json();
};

POST request with body:

const createUser = async (userData: UserData) => {
  const response = await fetch("https://api.example.com/users", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(userData),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message);
  }

  return response.json();
};

2. React Query (TanStack Query)

Setup:

// app/_layout.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      retry: 2,
    },
  },
});

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Stack />
    </QueryClientProvider>
  );
}

Fetching data:

import { useQuery } from "@tanstack/react-query";

function UserProfile({ userId }: { userId: string }) {
  const { data, fetchStatus, error, refetch } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });

  if (data === undefined) {
    if (error) return <ErrorState message={error.message} onRetry={() => refetch()} />;
    if (fetchStatus === "paused") return <OfflineState />;
    return <Loading />;
  }

  return (
    <>
      {error && <InlineError message="Could not refresh. Showing saved data." onRetry={() => refetch()} />}
      {data === null ? <EmptyState message="User not found" /> : <Profile user={data} />}
    </>
  );
}

Mutations:

import { useMutation, useQueryClient } from "@tanstack/react-query";

function CreateUserForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });

  const handleSubmit = (data: UserData) => {
    if (mutation.isPending) return;
    mutation.mutate(data);
  };

  // Form keeps its draft on error and disables Submit while isLoading.
  return <Form onSubmit={handleSubmit} isLoading={mutation.isPending} error={mutation.error?.message} />;
}

3. Error Handling

Comprehensive error handling:

class ApiError extends Error {
  constructor(message: string, public status: number, public code?: string) {
    super(message);
    this.name = "ApiError";
  }
}

const fetchWithErrorHandling = async (url: string, options?: RequestInit) => {
  try {
    const response = await fetch(url, options);

    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new ApiError(
        error.message || "Request failed",
        response.status,
        error.code
      );
    }

    return response.json();
  } catch (error) {
    if (error instanceof ApiError) {
      throw error;
    }
    // Network error (no internet, timeout, etc.)
    throw new ApiError("Network error", 0, "NETWORK_ERROR");
  }
};

Retry logic:

const fetchWithRetry = async (
  url: string,
  options?: RequestInit,
  retries = 3
) => {
  for (let i = 0; i < retries; i++) {
    try {
      return await fetchWithErrorHandling(url, options);
    } catch (error) {
      if (i === retries - 1) throw error;
      // Exponential backoff
      await new Promise((r) => setTimeout(r, Math.pow(2, i) * 1000));
    }
  }
};

4. Authentication

Token management:

import * as SecureStore from "expo-secure-store";

const TOKEN_KEY = "auth_token";

export const auth = {
  getToken: () => SecureStore.getItemAsync(TOKEN_KEY),
  setToken: (token: string) => SecureStore.setItemAsync(TOKEN_KEY, token),
  removeToken: () => SecureStore.deleteItemAsync(TOKEN_KEY),
};

// Authenticated fetch wrapper
const authFetch = async (url: string, options: RequestInit = {}) => {
  const token = await auth.getToken();

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: token ? `Bearer ${token}` : "",
    },
  });
};

Token refresh:

let isRefreshing = false;
let refreshPromise: Promise<string> | null = null;

const getValidToken = async (): Promise<string> => {
  const token = await auth.getToken();

  if (!token || isTokenExpired(token)) {
    if (!isRefreshing) {
      isRefreshing = true;
      refreshPromise = refreshToken().finally(() => {
        isRefreshing = false;
        refreshPromise = null;
      });
    }
    return refreshPromise!;
  }

  return token;
};

5. Offline Support

Network-status detection with NetInfo and offline-first React Query setup: see ./references/offline-and-cancellation.md.


6. Environment Variables

Using environment variables for API configuration:

Expo supports environment variables with the EXPO_PUBLIC_ prefix. These are inlined at build time and available in your JavaScript code.

// .env
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_API_VERSION=v1

// Usage in code
const API_URL = process.env.EXPO_PUBLIC_API_URL;

const fetchUsers = async () => {
  const response = await fetch(`${API_URL}/users`);
  return response.json();
};

Environment-specific configuration:

// .env.development
EXPO_PUBLIC_API_URL=http://localhost:3000

// .env.production
EXPO_PUBLIC_API_URL=https://api.production.com

Creating an API client with environment config:

// api/client.ts
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;

if (!BASE_URL) {
  throw new Error("EXPO_PUBLIC_API_URL is not defined");
}

export const apiClient = {
  get: async <T,>(path: string): Promise<T> => {
    const response = await fetch(`${BASE_URL}${path}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  },

  post: async <T,>(path: string, body: unknown): Promise<T> => {
    const response = await fetch(`${BASE_URL}${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  },
};

Important notes:

  • Only variables prefixed with EXPO_PUBLIC_ are exposed to the client bundle
  • Never put secrets (API keys with write access, database passwords) in EXPO_PUBLIC_ variables—they're visible in the built app
  • Environment variables are inlined at build time, not runtime
  • Restart the dev server after changing .env files
  • For server-side secrets in API routes, use variables without the EXPO_PUBLIC_ prefix

TypeScript support:

// types/env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      EXPO_PUBLIC_API_URL: string;
      EXPO_PUBLIC_API_VERSION?: string;
    }
  }
}

export {};

7. Request Cancellation

AbortController on unmount (React Query cancels automatically): see ./references/offline-and-cancellation.md.


Decision Tree

User asks about networking
  |-- Route-level data loading (web, SDK 55+)?
  |   \-- Expo Router loaders — see references/expo-router-loaders.md
  |
  |-- Basic fetch?
  |   \-- Use fetch API with error handling
  |
  |-- Need caching/state management?
  |   |-- Complex app -> React Query (TanStack Query)
  |   \-- Simpler needs -> SWR or custom hooks
  |
  |-- Authentication?
  |   |-- Token storage -> expo-secure-store
  |   \-- Token refresh -> Implement refresh flow
  |
  |-- Error handling?
  |   |-- Network errors -> Check connectivity first
  |   |-- HTTP errors -> Parse response, throw typed errors
  |   \-- Retries -> Exponential backoff
  |
  |-- Offline support?
  |   |-- Check status -> NetInfo
  |   \-- Queue requests -> React Query persistence
  |
  |-- Environment/API config?
  |   |-- Client-side URLs -> EXPO_PUBLIC_ prefix in .env
  |   |-- Server secrets -> Non-prefixed env vars (API routes only)
  |   \-- Multiple environments -> .env.development, .env.production
  |
  \-- Performance?
      |-- Caching -> React Query with staleTime
      |-- Deduplication -> React Query handles this
      \-- Cancellation -> AbortController or React Query

Common Mistakes

Wrong: No error handling

const data = await fetch(url).then((r) => r.json());

Right: Check response status

const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();

Wrong: Storing tokens in AsyncStorage

await AsyncStorage.setItem("token", token); // Not secure!

Right: Use SecureStore for sensitive data

await SecureStore.setItemAsync("token", token);

Example Invocations

User: "How do I make API calls in React Native?" -> Use fetch, wrap with error handling

User: "Should I use React Query or SWR?" -> React Query for complex apps, SWR for simpler needs

User: "My app needs to work offline" -> Use NetInfo for status, React Query persistence for caching

User: "How do I handle authentication tokens?" -> Store in expo-secure-store, implement refresh flow

User: "API calls are slow" -> Check caching strategy, use React Query staleTime User: "How do I configure different API URLs for dev and prod?" -> Use EXPO_PUBLIC_ env vars with .env.development and .env.production files User: "Where should I put my API key?" -> Client-safe keys: EXPO_PUBLIC_ in .env. Secret keys: non-prefixed env vars in API routes only

User: "How do I load data for a page in Expo Router?" -> See references/expo-router-loaders.md for route-level loaders (web, SDK 55+). For native, use React Query or fetch.

Submitting Feedback

If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve:

npx --yes submit-expo-feedback@latest --category skills --subject "expo-data-fetching" "<actionable feedback>"

Only submit when you have something specific and actionable to report. Include as much relevant context as possible. If an AI agent repeatedly failed or the user had to take over an Expo task, load the expo-skill-feedback skill and follow its eval-candidate flow instead of reusing the command above.

來自 expo 的更多技能

expo-observe
expo
Use for anything related to EAS Observe — adding `expo-observe` to an Expo project (AppMetricsRoot/ObserveRoot HOC, markInteractive, the useObserve hook, the…
upgrading-expo
expo
結構化指引,協助升級 Expo SDK 版本並解決相依性衝突。提供逐步升級流程,包括診斷、清除快取,以及針對原生變更的預建工作流程。涵蓋 SDK 53–55 的重大變更,包含 React 19 遷移、新架構預設值、React Compiler 設定,以及原生模組更新(tabs、audio、video)。內含套件棄用地圖,例如 expo-av、expo-permissions 與 AsyncStorage,並提供替代建議...
expo-upgrade
expo
框架(開源)。升級 Expo SDK 版本與修復相依性問題的指南。
apidevelopmenttesting
expo-ui-swift-ui
expo
`@expo/ui/swift-ui` package lets you use SwiftUI Views and modifiers in your app.
serve-sim
expo
控制並串流運行中的 iOS、iPad 或 Apple Watch 模擬器,使用 npx serve-sim。用於模擬器預覽、點擊、手勢、硬體按鈕、旋轉等。
serve-sim-placeholder-assets
expo
審核並更新 serve-sim 的 Device Hub 風格模擬器佔位資源,從本機 Xcode/CoreTypes 資源中取得。請在安裝新版 Xcode 或 macOS 後使用…
add-app-clip
expo
為Expo應用程式新增iOS App Clip目標。當使用者提及App Clip、AASA、apple-app-site-association、appclips、智慧型應用程式橫幅,或希望發佈…時使用。
building-native-ui
expo
使用路由、樣式、元件及平台慣例建構原生 Expo 應用程式的完整指南。涵蓋 Expo Router 基礎、原生分頁、堆疊導航、模態視窗與表單頁面,並詳述路由結構慣例。包含符合 Apple 人機介面指南的樣式規則:flexbox 佈局、安全區域處理、動畫、透過 CSS boxShadow 實現的陰影效果,以及響應式設計模式。記錄函式庫偏好設定(expo-image 用於 SF Symbols、expo-audio、expo-video……)