Generated banner for Native Data Fetching
Generated logo for Native Data Fetching

Native Data Fetching

OpenAIOfficialCreated by Expo TeamDistributed by OpenAI

  • Skill
  • Tool integration
  • Software development
  • MIT
  • No issues found

From the author

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`).
— the description by Expo Team

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a standardized approach to networking within the Expo ecosystem. It covers essential patterns for data fetching using the native fetch API, advanced state management with TanStack Query, and route-level data loading via Expo Router. It emphasizes robust error handling, secure token management using SecureStore, offline-first strategies, and proper environment variable configuration.

What it does

  • Provides patterns for standard GET and POST requests using the native fetch API.
  • Integrates TanStack Query for efficient data caching, synchronization, and state management.
  • Implements secure authentication token storage and automatic refresh flows.
  • Configures environment-specific API endpoints using EXPO_PUBLIC_ variables.
  • Handles network connectivity monitoring and offline-first application states.
  • Supports route-level data loading for web-based Expo applications.

When to use it

  • When building or debugging any network-related functionality in an Expo project.
  • When implementing authentication flows or managing sensitive API tokens.
  • When setting up data fetching strategies for complex application states.
  • When configuring environment variables for development, staging, or production APIs.
  • When addressing network failures, timeouts, or offline connectivity requirements.

Advantages

  • Promotes consistent networking patterns across the entire application.
  • Ensures security by recommending SecureStore over insecure storage methods.
  • Reduces boilerplate code for common tasks like error handling and retries.
  • Optimizes performance through effective caching and request deduplication.
  • Provides clear guidance on environment variable management for different build targets.

Usage examples

“How do I make a secure API call in my React Native app?”
The agent will provide a fetch wrapper that includes Authorization headers retrieved from SecureStore and standard error handling.
“How can I cache my API data to improve performance?”
The agent will guide you through setting up TanStack Query with appropriate staleTime and query keys.
“How do I handle offline states in my app?”
The agent will show you how to use NetInfo to monitor connectivity and configure TanStack Query for offline persistence.
“How do I configure different API URLs for dev and prod?”
The agent will explain how to use .env.development and .env.production files with the EXPO_PUBLIC_ prefix.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/openai/native-data-fetching/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip native-data-fetching.zip -d .agents/skills

Documentation by Expo Team

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+)

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

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, isLoading, error, refetch } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });

  if (isLoading) return <Loading />;
  if (error) return <Error message={error.message} />;

  return <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) => {
    mutation.mutate(data);
  };

  return <Form onSubmit={handleSubmit} isLoading={mutation.isPending} />;
}

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

Check network status:

import NetInfo from "@react-native-community/netinfo";

// Hook for network status
function useNetworkStatus() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    return NetInfo.addEventListener((state) => {
      setIsOnline(state.isConnected ?? true);
    });
  }, []);

  return isOnline;
}

Offline-first with React Query:

import { onlineManager } from "@tanstack/react-query";
import NetInfo from "@react-native-community/netinfo";

// Sync React Query with network status
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(state.isConnected ?? true);
  });
});

// Queries will pause when offline and resume when online

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

Cancel on unmount:

useEffect(() => {
  const controller = new AbortController();

  fetch(url, { signal: controller.signal })
    .then((response) => response.json())
    .then(setData)
    .catch((error) => {
      if (error.name !== "AbortError") {
        setError(error);
      }
    });

  return () => controller.abort();
}, [url]);

With React Query (automatic):

// React Query automatically cancels requests when queries are invalidated
// or components unmount

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 EXPOPUBLIC env vars with .env.development and .env.production files

User: "Where should I put my API key?" -> Client-safe keys: EXPOPUBLIC 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.

Files

references/expo-router-loaders.md

Expo Router Data Loaders

Route-level data loading for web apps using Expo SDK 55+. Loaders are async functions exported from route files that load data before the route renders, following the Remix/React Router loader model.

Dual execution model:

  • Initial page load (SSR): The loader runs server-side. Its return value is serialized as JSON and embedded in the HTML response.

  • Client-side navigation: The browser fetches the loader data from the server via HTTP. The route renders once the data arrives.

You write one function and the framework manages when and how it executes.

Configuration

Requirements: Expo SDK 55+, web output mode (npx expo serve or npx expo export --platform web) set in app.json or app.config.js.

Server rendering:

{
  "expo": {
    "web": {
      "output": "server"
    },
    "plugins": [
      ["expo-router", {
        "unstable_useServerDataLoaders": true,
        "unstable_useServerRendering": true
      }]
    ]
  }
}

Static/SSG:

{
  "expo": {
    "web": {
      "output": "static"
    },
    "plugins": [
      ["expo-router", {
        "unstable_useServerDataLoaders": true
      }]
    ]
  }
}
"server""static"
unstable_useServerDataLoadersRequiredRequired
unstable_useServerRenderingRequiredNot required
Loader runs onLive server (every request)Build time (static generation)
request objectFull access (headers, cookies)Not available
HostingNode.js server (EAS Hosting)Any static host (Netlify, Vercel, S3)

Imports

Loaders use two packages:

  • expo-router — useLoaderData hook

  • expo-server — LoaderFunction type, StatusError, setResponseHeaders. Always available (dependency of expo-router), no install needed.

Basic Loader

For loaders without params, a plain async function works:

// app/posts/index.tsx
import { Suspense } from "react";
import { useLoaderData } from "expo-router";
import { ActivityIndicator, View, Text } from "react-native";

export async function loader() {
  const response = await fetch("https://api.example.com/posts");
  const posts = await response.json();
  return { posts };
}

function PostList() {
  const { posts } = useLoaderData<typeof loader>();

  return (
    <View>
      {posts.map((post) => (
        <Text key={post.id}>{post.title}</Text>
      ))}
    </View>
  );
}

export default function Posts() {
  return (
    <Suspense fallback={<ActivityIndicator size="large" />}>
      <PostList />
    </Suspense>
  );
}

useLoaderData is typed via typeof loader — the generic parameter infers the return type.

Dynamic Routes

For loaders with params, use the LoaderFunction<T> type from expo-server. The first argument is the request (an immutable Request-like object, or undefined in static mode). The second is params (Record<string, string | string[]>), which contains path parameters only. Access individual params with a cast like params.id as string. For query parameters, use new URL(request.url).searchParams:

// app/posts/[id].tsx
import { Suspense } from "react";
import { useLoaderData } from "expo-router";
import { StatusError, type LoaderFunction } from "expo-server";
import { ActivityIndicator, View, Text } from "react-native";

type Post = {
  id: number;
  title: string;
  body: string;
};

export const loader: LoaderFunction<{ post: Post }> = async (
  request,
  params,
) => {
  const id = params.id as string;
  const response = await fetch(`https://api.example.com/posts/${id}`);

  if (!response.ok) {
    throw new StatusError(404, `Post ${id} not found`);
  }

  const post: Post = await response.json();
  return { post };
};

function PostContent() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <View>
      <Text>{post.title}</Text>
      <Text>{post.body}</Text>
    </View>
  );
}

export default function PostDetail() {
  return (
    <Suspense fallback={<ActivityIndicator size="large" />}>
      <PostContent />
    </Suspense>
  );
}

Catch-all routes access params.slug the same way:

// app/docs/[...slug].tsx
import { type LoaderFunction } from "expo-server";

type Doc = { title: string; content: string };

export const loader: LoaderFunction<{ doc: Doc }> = async (request, params) => {
  const slug = params.slug as string[];
  const path = slug.join("/");
  const doc = await fetchDoc(path);
  return { doc };
};

Query parameters are available via the request object (server output mode only):

// app/search.tsx
import { type LoaderFunction } from "expo-server";

export const loader: LoaderFunction<{ results: any[]; query: string }> = async (request) => {
  // Assuming request.url is `/search?q=expo&page=2`
  const url = new URL(request!.url);
  const query = url.searchParams.get("q") ?? "";
  const page = Number(url.searchParams.get("page") ?? "1");

  const results = await fetchSearchResults(query, page);
  return { results, query };
};

Server-Side Secrets & Request Access

Loaders run on the server, so you can access secrets and server-only resources directly:

// app/dashboard.tsx
import { type LoaderFunction } from "expo-server";

export const loader: LoaderFunction<{ balance: any; isAuthenticated: boolean }> = async (
  request,
  params,
) => {
  const data = await fetch("https://api.stripe.com/v1/balance", {
    headers: {
      Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
    },
  });

  const sessionToken = request?.headers.get("cookie")?.match(/session=([^;]+)/)?.[1];

  const balance = await data.json();
  return { balance, isAuthenticated: !!sessionToken };
};

The request object is available in server output mode. In static output mode, request is always undefined.

Response Utilities

Setting Response Headers
// app/products.tsx
import { setResponseHeaders } from "expo-server";

export async function loader() {
  setResponseHeaders({
    "Cache-Control": "public, max-age=300",
  });

  const products = await fetchProducts();
  return { products };
}
Throwing HTTP Errors
// app/products/[id].tsx
import { StatusError, type LoaderFunction } from "expo-server";

export const loader: LoaderFunction<{ product: Product }> = async (request, params) => {
  const id = params.id as string;
  const product = await fetchProduct(id);

  if (!product) {
    throw new StatusError(404, "Product not found");
  }

  return { product };
};

Suspense & Error Boundaries

Loading States with Suspense

useLoaderData() suspends during client-side navigation. Push it into a child component and wrap with <Suspense>:

// app/posts/index.tsx
import { Suspense } from "react";
import { useLoaderData } from "expo-router";
import { ActivityIndicator, View, Text } from "react-native";

export async function loader() {
  const response = await fetch("https://api.example.com/posts");
  return { posts: await response.json() };
}

function PostList() {
  const { posts } = useLoaderData<typeof loader>();

  return (
    <View>
      {posts.map((post) => (
        <Text key={post.id}>{post.title}</Text>
      ))}
    </View>
  );
}

export default function Posts() {
  return (
    <Suspense
      fallback={
        <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
          <ActivityIndicator size="large" />
        </View>
      }
    >
      <PostList />
    </Suspense>
  );
}

The <Suspense> boundary must be above the component calling useLoaderData(). On initial page load the data is already in the HTML, suspension only occurs during client-side navigation.

Error Boundaries
// app/posts/[id].tsx
export function ErrorBoundary({ error }: { error: Error }) {
  return (
    <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
      <Text>Error: {error.message}</Text>
    </View>
  );
}

When a loader throws (including StatusError), the nearest ErrorBoundary catches it.

Static vs Server Rendering

Server ("server")Static ("static")
When loader runsEvery request (live)At build time (npx expo export)
Data freshnessFresh on initial server requestStale until next build
request objectFull accessNot available
HostingNode.js server (EAS Hosting)Any static host
Use casePersonalized/dynamic contentMarketing pages, blogs, docs

Choose server when data changes frequently or content is personalized (cookies, auth, headers).

Choose static when content is the same for all users and changes infrequently.

Best Practices

  • Loaders are web-only; use client-side fetching (React Query, fetch) for native

  • Loaders cannot be used in _layout files — only in route files

  • Use LoaderFunction<T> from expo-server to type loaders that use params

  • The request object is immutable — use optional chaining (request?.headers) as it may be undefined in static mode

  • Return only JSON-serializable values (no Date, Map, Set, class instances, functions)

  • Use non-prefixed process.env vars for secrets in loaders, not EXPO_PUBLIC_ (which is embedded in the client bundle)

  • Use StatusError from expo-server for HTTP error responses

  • Use setResponseHeaders from expo-server to set headers

  • Export ErrorBoundary from route files to handle loader failures gracefully

  • Validate and sanitize user input (params, query strings) before using in database queries or API calls

  • Handle errors gracefully with try/catch; log server-side for debugging

  • Loader data is currently cached for the session. This is a known limitation that will be lifted in a future release

Requirements and compatibility

Written for: OpenAI Codex

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs network access, Needs an API key

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Expo Team
Publisher
OpenAI (Official)
Distributed by
OpenAI
Source
openai/plugins · plugins/expo/skills/native-data-fetching · commit d420d13
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from plugins/expo/LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Expo Team or OpenAI.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download native-data-fetching.zip