native-data-fetching

star 27

Use when implementing network requests, API calls, data caching, or offline support in React Native or Expo apps, including authentication token management and request lifecycle handling

bidah By bidah schedule Updated 4/8/2026

name: native-data-fetching description: Use when implementing network requests, API calls, data caching, or offline support in React Native or Expo apps, including authentication token management and request lifecycle handling

Data Fetching in React Native

Overview

React Native uses the standard Fetch API but requires additional patterns for production apps: caching, mutation handling, authentication, offline support, and request cancellation. TanStack Query (React Query) is the standard solution for managing server state.

Core principle: Separate server state (fetched data) from client state (UI state). Use React Query for server state and React state/context for client state.

When to Use

  • Making HTTP requests to REST or GraphQL APIs
  • Caching API responses to avoid redundant network calls
  • Handling optimistic updates and mutations
  • Managing authentication tokens securely
  • Supporting offline-first or intermittent connectivity
  • Cancelling requests on component unmount or navigation

Fetch API in React Native

Basic Request

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

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

  return response.json();
}

POST with JSON Body

async function createPost(title: string, body: string, token: string) {
  const response = await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ title, body }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message ?? 'Failed to create post');
  }

  return response.json();
}

File Upload

async function uploadImage(uri: string, token: string) {
  const formData = new FormData();
  formData.append('image', {
    uri,
    type: 'image/jpeg',
    name: 'photo.jpg',
  } as any);

  const response = await fetch('https://api.example.com/upload', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      // Do NOT set Content-Type; fetch sets it with boundary for FormData
    },
    body: formData,
  });

  return response.json();
}

React Query (TanStack Query)

Setup

npx expo install @tanstack/react-query
// app/_layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,    // 5 minutes
      gcTime: 10 * 60 * 1000,       // 10 minutes (formerly cacheTime)
      retry: 2,
      refetchOnWindowFocus: false,   // Not relevant on mobile
    },
  },
});

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

Queries

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 <ActivityIndicator />;
  if (error) return <Text>Error: {error.message}</Text>;

  return (
    <View>
      <Text>{data.name}</Text>
      <Text>{data.email}</Text>
    </View>
  );
}

Dependent Queries

function UserPosts({ userId }: { userId: string }) {
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  const postsQuery = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchPostsByUser(userId),
    enabled: !!userQuery.data,  // Only fetch when user is loaded
  });

  // ...
}

Infinite Queries (Pagination)

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

function InfiniteFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetchFeed(pageParam),
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
  });

  const items = data?.pages.flatMap((page) => page.items) ?? [];

  return (
    <FlatList
      data={items}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <FeedItem item={item} />}
      onEndReached={() => {
        if (hasNextPage) fetchNextPage();
      }}
      onEndReachedThreshold={0.5}
      ListFooterComponent={
        isFetchingNextPage ? <ActivityIndicator /> : null
      }
    />
  );
}

Mutations

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

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

  const mutation = useMutation({
    mutationFn: (newPost: { title: string; body: string }) =>
      createPost(newPost.title, newPost.body),

    onSuccess: () => {
      // Invalidate and refetch posts list
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  const handleSubmit = () => {
    mutation.mutate({ title, body });
  };

  return (
    <View>
      {/* Form fields... */}
      <Pressable
        onPress={handleSubmit}
        disabled={mutation.isPending}
      >
        <Text>{mutation.isPending ? 'Posting...' : 'Submit'}</Text>
      </Pressable>
      {mutation.isError && (
        <Text style={{ color: 'red' }}>{mutation.error.message}</Text>
      )}
    </View>
  );
}

Optimistic Updates

const likeMutation = useMutation({
  mutationFn: (postId: string) => likePost(postId),

  onMutate: async (postId) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['posts'] });

    // Snapshot previous value
    const previousPosts = queryClient.getQueryData(['posts']);

    // Optimistically update
    queryClient.setQueryData(['posts'], (old: Post[]) =>
      old.map((post) =>
        post.id === postId
          ? { ...post, likes: post.likes + 1, liked: true }
          : post
      )
    );

    return { previousPosts };
  },

  onError: (_err, _postId, context) => {
    // Rollback on error
    queryClient.setQueryData(['posts'], context?.previousPosts);
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

Authentication with expo-secure-store

Store tokens securely using the device keychain (iOS) or EncryptedSharedPreferences (Android):

npx expo install expo-secure-store
// lib/auth.ts
import * as SecureStore from 'expo-secure-store';

const TOKEN_KEY = 'auth_token';
const REFRESH_TOKEN_KEY = 'refresh_token';

export async function saveTokens(accessToken: string, refreshToken: string) {
  await SecureStore.setItemAsync(TOKEN_KEY, accessToken);
  await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refreshToken);
}

export async function getAccessToken(): Promise<string | null> {
  return SecureStore.getItemAsync(TOKEN_KEY);
}

export async function clearTokens() {
  await SecureStore.deleteItemAsync(TOKEN_KEY);
  await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
}

Authenticated Fetch Wrapper

// lib/api.ts
import { getAccessToken, saveTokens, clearTokens } from './auth';

const BASE_URL = process.env.EXPO_PUBLIC_API_URL;

export async function apiFetch(path: string, options: RequestInit = {}) {
  const token = await getAccessToken();

  const response = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
  });

  // Handle token expiry
  if (response.status === 401) {
    const refreshed = await refreshAccessToken();
    if (refreshed) {
      // Retry with new token
      return apiFetch(path, options);
    }
    // Refresh failed, clear tokens and redirect to login
    await clearTokens();
    throw new AuthError('Session expired');
  }

  if (!response.ok) {
    throw new ApiError(response.status, await response.text());
  }

  return response.json();
}

Offline Support with NetInfo

npx expo install @react-native-community/netinfo

Online Status Hook

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

// Tell React Query about network status
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected);
  });
});

Offline-Aware Component

import { useNetInfo } from '@react-native-community/netinfo';

function DataScreen() {
  const netInfo = useNetInfo();
  const { data, isLoading } = useQuery({
    queryKey: ['items'],
    queryFn: fetchItems,
  });

  if (!netInfo.isConnected && !data) {
    return (
      <View style={styles.offline}>
        <Text>No internet connection</Text>
        <Text>Previously cached data unavailable</Text>
      </View>
    );
  }

  // ...
}

Environment Configuration

// app.json
{
  "expo": {
    "extra": {
      "eas": {
        "projectId": "your-project-id"
      }
    }
  }
}

Use EXPO_PUBLIC_ prefix for client-visible environment variables:

# .env
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_SENTRY_DSN=https://sentry.io/...
// Access in code
const apiUrl = process.env.EXPO_PUBLIC_API_URL;

For per-environment config with EAS:

eas env:create --name EXPO_PUBLIC_API_URL --value https://api.staging.example.com --environment preview
eas env:create --name EXPO_PUBLIC_API_URL --value https://api.example.com --environment production

Request Cancellation

With AbortController

function SearchResults({ query }: { query: string }) {
  const { data } = useQuery({
    queryKey: ['search', query],
    queryFn: ({ signal }) => {
      // signal is automatically provided by React Query
      return fetch(`/api/search?q=${query}`, { signal }).then((r) => r.json());
    },
    enabled: query.length > 2,
  });

  // Query is automatically cancelled when:
  // - Component unmounts
  // - Query key changes (new search)
  // - Query is manually cancelled
}

Manual Cancellation

const queryClient = useQueryClient();

// Cancel all queries matching a key
queryClient.cancelQueries({ queryKey: ['search'] });

// Cancel specific query
queryClient.cancelQueries({ queryKey: ['search', 'react native'] });

On-Device Storage

When the project needs key-value storage, ask the user which option fits their setup:

Option Pros Cons Requires Prebuild?
react-native-mmkv (recommended) Synchronous API, ~30x faster than AsyncStorage, encryption support, Zustand integration Requires native modules — needs npx expo prebuild or a dev client Yes
AsyncStorage Works in Expo Go out of the box, no prebuild needed, simple async API Asynchronous only, slower, no encryption No

Which should I use? If you're already using a dev client or have run npx expo prebuild, use MMKV. If you need to stay in Expo Go without prebuilding, use AsyncStorage.

react-native-mmkv

Synchronous, ~30x faster than AsyncStorage, built on WeChat's battle-tested C++ library. Requires a prebuild (native module via Nitro Modules).

npx expo install react-native-mmkv react-native-nitro-modules
npx expo prebuild

Basic Usage

import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();

// Write (synchronous)
storage.set('user.name', 'Jane');
storage.set('user.age', 28);
storage.set('onboarded', true);

// Read (synchronous)
const name = storage.getString('user.name');    // 'Jane'
const age = storage.getNumber('user.age');       // 28
const onboarded = storage.getBoolean('onboarded'); // true

// Delete
storage.delete('user.name');

// Check existence
if (storage.contains('user.age')) { /* ... */ }

// Clear all
storage.clearAll();

Storing Objects

// MMKV stores primitives — serialize objects as JSON
function setObject<T>(key: string, value: T) {
  storage.set(key, JSON.stringify(value));
}

function getObject<T>(key: string): T | undefined {
  const value = storage.getString(key);
  return value ? JSON.parse(value) : undefined;
}

// Usage
setObject('user.preferences', { theme: 'dark', language: 'en' });
const prefs = getObject<{ theme: string; language: string }>('user.preferences');

With Zustand (Persisted State)

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();

const mmkvStorage = {
  getItem: (name: string) => storage.getString(name) ?? null,
  setItem: (name: string, value: string) => storage.set(name, value),
  removeItem: (name: string) => storage.delete(name),
};

const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'light' as 'light' | 'dark',
      setTheme: (theme: 'light' | 'dark') => set({ theme }),
    }),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => mmkvStorage),
    },
  ),
);

Encrypted Storage

const secureStorage = new MMKV({
  id: 'secure-storage',
  encryptionKey: 'your-encryption-key',
});

Note: For authentication tokens and secrets, prefer expo-secure-store (uses iOS Keychain / Android EncryptedSharedPreferences). Use MMKV for app data, preferences, caches, and non-sensitive state.

AsyncStorage

Works in Expo Go without prebuild. Simple async key-value storage.

npx expo install @react-native-async-storage/async-storage
import AsyncStorage from '@react-native-async-storage/async-storage';

// Write (asynchronous)
await AsyncStorage.setItem('user.name', 'Jane');

// Read (asynchronous)
const name = await AsyncStorage.getItem('user.name'); // 'Jane' | null

// Store objects (must serialize manually)
await AsyncStorage.setItem('prefs', JSON.stringify({ theme: 'dark' }));
const prefs = JSON.parse((await AsyncStorage.getItem('prefs')) ?? '{}');

// Delete
await AsyncStorage.removeItem('user.name');

// Clear all
await AsyncStorage.clear();

With Zustand (Persisted State)

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'light' as 'light' | 'dark',
      setTheme: (theme: 'light' | 'dark') => set({ theme }),
    }),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => AsyncStorage),
    },
  ),
);

Common Mistakes

Mistake Fix
Storing tokens in AsyncStorage Use expo-secure-store for sensitive credentials
Using AsyncStorage when prebuild is available Use react-native-mmkv — synchronous, ~30x faster
Setting refetchOnWindowFocus: true on mobile Mobile apps don't have window focus events like web; set to false
Not handling 401 token expiry Implement token refresh in your fetch wrapper
Fetching in useEffect without cleanup Use React Query or AbortController to cancel on unmount
Hardcoding API URLs Use EXPO_PUBLIC_* env vars with EAS environments
Not setting staleTime Default 0 means every mount refetches; set appropriate stale time
Missing error boundaries Wrap screens with error boundaries for network failures
Using cacheTime (renamed) Use gcTime (garbage collection time) in v5

Quick Reference

Task Pattern
Basic fetch fetch(url).then(r => r.json())
Setup React Query <QueryClientProvider client={queryClient}>
Query data useQuery({ queryKey, queryFn })
Mutate data useMutation({ mutationFn, onSuccess })
Paginated list useInfiniteQuery with FlatList.onEndReached
Store token SecureStore.setItemAsync(key, value)
Network status NetInfo.addEventListener + onlineManager
Cancel request Pass signal from queryFn to fetch
Invalidate cache queryClient.invalidateQueries({ queryKey })
Local storage MMKV from react-native-mmkv (not AsyncStorage)
Env variable process.env.EXPO_PUBLIC_API_URL
Install via CLI
npx skills add https://github.com/bidah/react-native-hifi --skill native-data-fetching
Repository Details
star Stars 27
call_split Forks 1
navigation Branch main
article Path SKILL.md
More from Creator