Skip to main content

Module 7: Data Management and Networking

Fetching Data

Connecting your React Native app to the outside world

🎯 Learning Objectives

  • Use the fetch() API in React Native just like you do on the web
  • Implement the loading → success → error state pattern
  • Cancel pending requests with AbortController to prevent memory leaks
  • Configure environment variables for different API endpoints
  • Handle common networking scenarios: timeouts, retries, and error responses
  • Build a reusable data fetching hook

The Good News: fetch() Just Works

Here's something refreshing after all the "mobile is different" discussions: data fetching in React Native works exactly like it does on the web. The fetch() API is built right into React Native's JavaScript runtime. No special libraries needed, no platform-specific code required.

✅ Great News for Web Developers

If you've used fetch() in React web apps, you already know how to fetch data in React Native. Same API, same patterns, same Promise-based flow. This is one area where your web skills transfer completely.

That said, there are mobile-specific considerations we'll cover: handling network state changes, dealing with slower connections, and managing background/foreground transitions. But the core mechanics? Identical.

flowchart TB
    subgraph transfers["✅ Transfers from Web"]
        F1["fetch() API"]
        F2["async/await syntax"]
        F3["JSON parsing"]
        F4["Request/Response objects"]
        F5["Headers configuration"]
        F6["AbortController"]
    end
    
    subgraph newstuff["📱 Mobile Considerations"]
        M1["Network state detection"]
        M2["Background fetch limits"]
        M3["Slower connections"]
        M4["App lifecycle impact"]
    end
    
    style transfers fill:#e8f5e9,stroke:#4CAF50
    style newstuff fill:#fff3e0,stroke:#FF9800
                

Basic Data Fetching

Let's start with the simplest possible example—fetching data when a component mounts. If you've done this in React web apps, this will look very familiar:

import { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';

interface User {
  id: number;
  name: string;
  email: string;
}

export default function UserProfile() {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users/1')
      .then(response => response.json())
      .then(data => setUser(data));
  }, []);

  if (!user) {
    return <Text>Loading...</Text>;
  }

  return (
    <View style={styles.container}>
      <Text style={styles.name}>{user.name}</Text>
      <Text style={styles.email}>{user.email}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 20,
  },
  name: {
    fontSize: 24,
    fontWeight: 'bold',
  },
  email: {
    fontSize: 16,
    color: '#666',
  },
});

This works, but it has problems. What if the request fails? What if the user navigates away before the request completes? Let's build this properly.

⚠️ The Code Above Has Issues

This naive approach has several problems we'll fix throughout this lesson: no error handling, no loading state management, and potential memory leaks if the component unmounts before the fetch completes. Never ship code like this!

The Loading State Trinity

Every data fetch operation has three possible outcomes: it's loading, it succeeded, or it failed. Your UI needs to handle all three states gracefully. This pattern is so common it has a name: the Loading State Trinity (or sometimes "loading/error/data" pattern).

The Loading State Trinity LOADING Spinner 200 OK Error SUCCESS Show Data ERROR Show Message Retry Refresh State Variables { isLoading: boolean, error: Error | null, data: T | null }

Here's the proper implementation with all three states:

import { useState, useEffect } from 'react';
import { 
  View, 
  Text, 
  StyleSheet, 
  ActivityIndicator,
  Pressable 
} from 'react-native';

interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
}

export default function UserProfile() {
  // The Trinity: loading, error, data
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [user, setUser] = useState<User | null>(null);

  const fetchUser = async () => {
    // Reset states before fetching
    setIsLoading(true);
    setError(null);
    
    try {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/users/1'
      );
      
      // Check if the response is OK (status 200-299)
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const data = await response.json();
      setUser(data);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
    } finally {
      // Always set loading to false when done
      setIsLoading(false);
    }
  };

  useEffect(() => {
    fetchUser();
  }, []);

  // Render based on state
  if (isLoading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#667eea" />
        <Text style={styles.loadingText}>Loading profile...</Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.centered}>
        <Text style={styles.errorText}>😢 Something went wrong</Text>
        <Text style={styles.errorMessage}>{error.message}</Text>
        <Pressable style={styles.retryButton} onPress={fetchUser}>
          <Text style={styles.retryText}>Try Again</Text>
        </Pressable>
      </View>
    );
  }

  if (!user) {
    return (
      <View style={styles.centered}>
        <Text>No user found</Text>
      </View>
    );
  }

  // Success! Show the data
  return (
    <View style={styles.container}>
      <View style={styles.card}>
        <Text style={styles.name}>{user.name}</Text>
        <Text style={styles.detail}>📧 {user.email}</Text>
        <Text style={styles.detail}>📱 {user.phone}</Text>
      </View>
      
      <Pressable style={styles.refreshButton} onPress={fetchUser}>
        <Text style={styles.refreshText}>🔄 Refresh</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
  },
  centered: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  loadingText: {
    marginTop: 12,
    fontSize: 16,
    color: '#666',
  },
  errorText: {
    fontSize: 20,
    fontWeight: 'bold',
    marginBottom: 8,
  },
  errorMessage: {
    fontSize: 14,
    color: '#666',
    marginBottom: 20,
    textAlign: 'center',
  },
  retryButton: {
    backgroundColor: '#667eea',
    paddingHorizontal: 24,
    paddingVertical: 12,
    borderRadius: 8,
  },
  retryText: {
    color: 'white',
    fontWeight: '600',
  },
  card: {
    backgroundColor: 'white',
    borderRadius: 12,
    padding: 20,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 8,
    elevation: 3,
  },
  name: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 12,
  },
  detail: {
    fontSize: 16,
    color: '#666',
    marginBottom: 8,
  },
  refreshButton: {
    marginTop: 20,
    padding: 12,
    alignItems: 'center',
  },
  refreshText: {
    color: '#667eea',
    fontSize: 16,
    fontWeight: '600',
  },
});

💡 Why Three Separate States?

You might wonder why we don't use a single status enum. While that pattern exists (and libraries like React Query use it), separate booleans are often clearer for beginners and work well for simple cases. We'll see the enum approach when we build our custom hook later.

The Order of Conditionals Matters

Notice the order in our render logic: loading → error → no data → success. This order is intentional:

// ✅ Correct order
if (isLoading) return <Loading />;      // Check loading first
if (error) return <Error />;             // Then check errors  
if (!data) return <Empty />;             // Then check for no data
return <Content data={data} />;          // Finally render content

// ❌ Wrong order - might show error while still loading
if (error) return <Error />;
if (isLoading) return <Loading />;       // Too late!
// ...

Abort Controllers: Preventing Memory Leaks

Here's a scenario that will bite you if you're not careful: the user navigates to a screen, a fetch starts, but then they quickly navigate away. The fetch completes, tries to call setState... but the component is gone. React will warn you about "Can't perform a React state update on an unmounted component."

The fix is AbortController—a web API that also works in React Native. It lets you cancel pending requests when a component unmounts.

sequenceDiagram
    participant C as Component
    participant AC as AbortController
    participant F as fetch()
    participant S as Server
    
    C->>AC: Create controller
    C->>F: Start fetch with signal
    F->>S: Request data
    
    Note over C: User navigates away
    C->>AC: abort()
    AC->>F: Signal abort
    F--xS: Request cancelled
    
    Note over C: Cleanup effect runs
    Note over F: AbortError thrown
    Note over C: No setState called ✓
                

Here's how to implement it:

import { useState, useEffect } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';

interface Post {
  id: number;
  title: string;
  body: string;
}

export default function PostDetail({ postId }: { postId: number }) {
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [post, setPost] = useState<Post | null>(null);

  useEffect(() => {
    // Create an AbortController for this effect
    const abortController = new AbortController();

    const fetchPost = async () => {
      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/posts/${postId}`,
          { signal: abortController.signal }  // Pass the signal to fetch
        );

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

        const data = await response.json();
        
        // Only update state if not aborted
        setPost(data);
      } catch (err) {
        // Don't set error state if we aborted intentionally
        if (err instanceof Error && err.name === 'AbortError') {
          console.log('Fetch aborted');
          return;  // Exit early, don't update state
        }
        setError(err instanceof Error ? err : new Error('Unknown error'));
      } finally {
        // Only set loading false if not aborted
        if (!abortController.signal.aborted) {
          setIsLoading(false);
        }
      }
    };

    fetchPost();

    // Cleanup function - runs when component unmounts
    // or when postId changes
    return () => {
      abortController.abort();
    };
  }, [postId]);  // Re-fetch when postId changes

  // ... render logic
}

⚠️ Always Check for AbortError

When a fetch is aborted, it throws an AbortError. You must check for this and handle it differently from real errors. Otherwise, your users might see "AbortError" messages when they're just navigating normally!

The Cleanup Pattern

The key insight is that useEffect's cleanup function runs when the component unmounts OR when dependencies change (causing a re-run of the effect). Both cases need the abort:

useEffect(() => {
  const controller = new AbortController();
  
  // ... fetch with controller.signal
  
  return () => {
    // This runs when:
    // 1. Component unmounts (navigating away)
    // 2. postId changes (new fetch starts)
    controller.abort();
  };
}, [postId]);

✅ Pro Tip: Abort on Dependency Changes Too

If postId changes from 1 to 2, we want to abort the fetch for post 1 before starting the fetch for post 2. The cleanup function handles this automatically—it runs before the next effect execution.

Error Handling Done Right

Network requests can fail in many ways. Good error handling means giving users actionable information, not just "Something went wrong." Let's categorize the errors you'll encounter:

Types of Network Errors 🌐 Network Errors • No internet connection • DNS resolution failed • Request timeout • Server unreachable fetch() throws Error 📋 HTTP Errors • 400 Bad Request • 401 Unauthorized • 404 Not Found • 500 Server Error response.ok === false 📄 Parse Errors • Invalid JSON • Unexpected format • Missing fields • Type mismatches response.json() throws ✅ User-Friendly Error Handling Network Error → "Check your internet connection" 401 Error → "Please log in again" 404 Error → "This item no longer exists" 500 Error → "Server issue, try again later"

Here's a comprehensive error handling approach:

// Custom error class for API errors
class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public code?: string
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

// Helper to get user-friendly error messages
function getErrorMessage(error: Error): string {
  // Network errors (no response at all)
  if (error.message === 'Network request failed') {
    return 'Please check your internet connection and try again.';
  }
  
  // Timeout errors
  if (error.name === 'TimeoutError') {
    return 'The request took too long. Please try again.';
  }
  
  // Abort errors (user navigated away)
  if (error.name === 'AbortError') {
    return ''; // Don't show anything, this is intentional
  }
  
  // API errors with status codes
  if (error instanceof ApiError) {
    switch (error.status) {
      case 400:
        return 'Invalid request. Please check your input.';
      case 401:
        return 'Your session has expired. Please log in again.';
      case 403:
        return 'You don\'t have permission to access this.';
      case 404:
        return 'The requested item was not found.';
      case 429:
        return 'Too many requests. Please wait a moment.';
      case 500:
      case 502:
      case 503:
        return 'Server error. Please try again later.';
      default:
        return `An error occurred (${error.status}).`;
    }
  }
  
  // Fallback for unknown errors
  return 'Something went wrong. Please try again.';
}

// Enhanced fetch function
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, options);
  
  if (!response.ok) {
    // Try to parse error details from response body
    let errorMessage = `HTTP error ${response.status}`;
    let errorCode: string | undefined;
    
    try {
      const errorBody = await response.json();
      errorMessage = errorBody.message || errorMessage;
      errorCode = errorBody.code;
    } catch {
      // Response body wasn't JSON, use default message
    }
    
    throw new ApiError(errorMessage, response.status, errorCode);
  }
  
  return response.json();
}

// Usage in component
const fetchData = async () => {
  try {
    const data = await apiFetch<User>('/api/user');
    setUser(data);
  } catch (error) {
    if (error instanceof Error) {
      const message = getErrorMessage(error);
      if (message) {  // Don't set error for AbortError
        setError(new Error(message));
      }
    }
  }
};

🔄 Retry Strategies

Some errors are worth retrying automatically:

  • Network errors — Might succeed when connection is restored
  • 5xx errors — Server might recover quickly
  • 429 (Rate Limited) — Wait and retry after delay

Don't auto-retry:

  • 4xx errors — These are client errors that won't change
  • Parse errors — The response won't magically become valid JSON

Environment Variables for API URLs

Hardcoding API URLs is a recipe for disaster. You need different URLs for development, staging, and production. In Expo, we use environment variables through app.config.js and expo-constants.

Setting Up Environment Variables

First, convert app.json to app.config.js (or app.config.ts):

// app.config.js
export default {
  expo: {
    name: 'MyApp',
    slug: 'my-app',
    version: '1.0.0',
    // ... other config
    extra: {
      // Access process.env at build time
      apiUrl: process.env.API_URL || 'https://api.dev.myapp.com',
      environment: process.env.APP_ENV || 'development',
    },
  },
};

Now access these in your code:

import Constants from 'expo-constants';

// Access the extra config
const { apiUrl, environment } = Constants.expoConfig?.extra ?? {};

console.log('API URL:', apiUrl);  // https://api.dev.myapp.com
console.log('Environment:', environment);  // development

// Use in fetch calls
async function fetchUsers() {
  const response = await fetch(`${apiUrl}/users`);
  return response.json();
}

Using .env Files

For local development, you can use .env files with a package like dotenv:

# .env.development
API_URL=https://api.dev.myapp.com
APP_ENV=development

# .env.staging
API_URL=https://api.staging.myapp.com
APP_ENV=staging

# .env.production
API_URL=https://api.myapp.com
APP_ENV=production

Update your app.config.js to load the right file:

// app.config.js
import 'dotenv/config';  // Load .env file

export default {
  expo: {
    name: process.env.APP_ENV === 'production' ? 'MyApp' : `MyApp (${process.env.APP_ENV})`,
    // ... rest of config
    extra: {
      apiUrl: process.env.API_URL,
      environment: process.env.APP_ENV,
    },
  },
};

⚠️ Security Warning

Environment variables in React Native are bundled into your app's JavaScript bundle. This means they're not truly "secret"—anyone can extract them from your app. Never put API keys for paid services, database credentials, or other secrets directly in your app. Use a backend proxy for sensitive operations.

Creating an API Client

With your environment configured, create a centralized API client:

// api/client.ts
import Constants from 'expo-constants';

const API_URL = Constants.expoConfig?.extra?.apiUrl;

if (!API_URL) {
  throw new Error('API_URL is not configured in app.config.js');
}

interface RequestOptions extends Omit<RequestInit, 'body'> {
  body?: object;
}

class ApiClient {
  private baseUrl: string;
  private defaultHeaders: HeadersInit;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
    this.defaultHeaders = {
      'Content-Type': 'application/json',
    };
  }

  setAuthToken(token: string) {
    this.defaultHeaders = {
      ...this.defaultHeaders,
      'Authorization': `Bearer ${token}`,
    };
  }

  clearAuthToken() {
    const { Authorization, ...rest } = this.defaultHeaders as Record<string, string>;
    this.defaultHeaders = rest;
  }

  async request<T>(
    endpoint: string,
    options: RequestOptions = {}
  ): Promise<T> {
    const { body, headers, ...rest } = options;

    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...rest,
      headers: {
        ...this.defaultHeaders,
        ...headers,
      },
      body: body ? JSON.stringify(body) : undefined,
    });

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

    // Handle empty responses
    const text = await response.text();
    return text ? JSON.parse(text) : null;
  }

  get<T>(endpoint: string, options?: RequestOptions) {
    return this.request<T>(endpoint, { ...options, method: 'GET' });
  }

  post<T>(endpoint: string, body?: object, options?: RequestOptions) {
    return this.request<T>(endpoint, { ...options, method: 'POST', body });
  }

  put<T>(endpoint: string, body?: object, options?: RequestOptions) {
    return this.request<T>(endpoint, { ...options, method: 'PUT', body });
  }

  delete<T>(endpoint: string, options?: RequestOptions) {
    return this.request<T>(endpoint, { ...options, method: 'DELETE' });
  }
}

// Export a singleton instance
export const api = new ApiClient(API_URL);

// Usage:
// import { api } from './api/client';
// const users = await api.get<User[]>('/users');
// await api.post('/users', { name: 'John', email: 'john@example.com' });

Building a Reusable useFetch Hook

We've been writing a lot of boilerplate: state variables, loading states, error handling, abort controllers. Let's encapsulate all of this into a reusable hook that we can use throughout our app.

// hooks/useFetch.ts
import { useState, useEffect, useCallback } from 'react';

// Define possible states as a union type
type FetchStatus = 'idle' | 'loading' | 'success' | 'error';

interface UseFetchState<T> {
  data: T | null;
  error: Error | null;
  status: FetchStatus;
  isLoading: boolean;
  isError: boolean;
  isSuccess: boolean;
}

interface UseFetchOptions {
  enabled?: boolean;  // Set to false to prevent auto-fetching
  onSuccess?: (data: any) => void;
  onError?: (error: Error) => void;
}

interface UseFetchResult<T> extends UseFetchState<T> {
  refetch: () => Promise<void>;
}

export function useFetch<T>(
  url: string,
  options: UseFetchOptions = {}
): UseFetchResult<T> {
  const { enabled = true, onSuccess, onError } = options;

  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    error: null,
    status: 'idle',
    isLoading: false,
    isError: false,
    isSuccess: false,
  });

  const fetchData = useCallback(async (signal?: AbortSignal) => {
    setState(prev => ({
      ...prev,
      status: 'loading',
      isLoading: true,
      isError: false,
      error: null,
    }));

    try {
      const response = await fetch(url, { signal });

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

      const data = await response.json();

      setState({
        data,
        error: null,
        status: 'success',
        isLoading: false,
        isError: false,
        isSuccess: true,
      });

      onSuccess?.(data);
    } catch (error) {
      // Ignore abort errors
      if (error instanceof Error && error.name === 'AbortError') {
        return;
      }

      const err = error instanceof Error ? error : new Error('Unknown error');

      setState({
        data: null,
        error: err,
        status: 'error',
        isLoading: false,
        isError: true,
        isSuccess: false,
      });

      onError?.(err);
    }
  }, [url, onSuccess, onError]);

  // Auto-fetch on mount and URL change
  useEffect(() => {
    if (!enabled) return;

    const controller = new AbortController();
    fetchData(controller.signal);

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

  // Manual refetch function
  const refetch = useCallback(async () => {
    await fetchData();
  }, [fetchData]);

  return {
    ...state,
    refetch,
  };
}

// Example usage:
// const { data, isLoading, isError, error, refetch } = useFetch<User[]>(
//   'https://api.example.com/users'
// );

Using the Hook

Now our components become much cleaner:

import { View, Text, FlatList, Pressable, ActivityIndicator } from 'react-native';
import { useFetch } from '../hooks/useFetch';

interface Post {
  id: number;
  title: string;
  body: string;
}

export default function PostList() {
  const { 
    data: posts, 
    isLoading, 
    isError, 
    error, 
    refetch 
  } = useFetch<Post[]>('https://jsonplaceholder.typicode.com/posts');

  if (isLoading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  if (isError) {
    return (
      <View style={styles.centered}>
        <Text>Error: {error?.message}</Text>
        <Pressable onPress={refetch}>
          <Text>Retry</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <FlatList
      data={posts}
      keyExtractor={item => item.id.toString()}
      renderItem={({ item }) => (
        <View style={styles.post}>
          <Text style={styles.title}>{item.title}</Text>
          <Text style={styles.body}>{item.body}</Text>
        </View>
      )}
      onRefresh={refetch}
      refreshing={isLoading}
    />
  );
}

💡 When to Use This vs React Query

This hook is perfect for learning and simple apps. For production apps with complex data requirements (caching, background refetching, optimistic updates, infinite queries), use a battle-tested library like React Query or SWR. We'll cover those in the next lesson!

Extended Hook with POST Support

Here's an extended version that supports mutations (POST, PUT, DELETE):

// hooks/useMutation.ts
import { useState, useCallback } from 'react';

interface UseMutationState<T> {
  data: T | null;
  error: Error | null;
  isLoading: boolean;
  isError: boolean;
  isSuccess: boolean;
}

interface UseMutationOptions<T> {
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
}

export function useMutation<TData, TVariables>(
  url: string,
  method: 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'POST',
  options: UseMutationOptions<TData> = {}
) {
  const { onSuccess, onError } = options;

  const [state, setState] = useState<UseMutationState<TData>>({
    data: null,
    error: null,
    isLoading: false,
    isError: false,
    isSuccess: false,
  });

  const mutate = useCallback(async (variables?: TVariables) => {
    setState(prev => ({
      ...prev,
      isLoading: true,
      isError: false,
      error: null,
    }));

    try {
      const response = await fetch(url, {
        method,
        headers: {
          'Content-Type': 'application/json',
        },
        body: variables ? JSON.stringify(variables) : undefined,
      });

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

      const data = await response.json();

      setState({
        data,
        error: null,
        isLoading: false,
        isError: false,
        isSuccess: true,
      });

      onSuccess?.(data);
      return data;
    } catch (error) {
      const err = error instanceof Error ? error : new Error('Unknown error');

      setState({
        data: null,
        error: err,
        isLoading: false,
        isError: true,
        isSuccess: false,
      });

      onError?.(err);
      throw err;
    }
  }, [url, method, onSuccess, onError]);

  const reset = useCallback(() => {
    setState({
      data: null,
      error: null,
      isLoading: false,
      isError: false,
      isSuccess: false,
    });
  }, []);

  return {
    ...state,
    mutate,
    reset,
  };
}

// Usage:
// const createPost = useMutation<Post, { title: string; body: string }>(
//   'https://api.example.com/posts',
//   'POST',
//   {
//     onSuccess: (newPost) => {
//       console.log('Created:', newPost);
//     },
//   }
// );
// 
// // In a handler:
// await createPost.mutate({ title: 'Hello', body: 'World' });

Real-World Patterns

Let's cover some patterns you'll need in production apps.

Adding Timeouts

fetch() doesn't have a built-in timeout, but we can implement one with AbortController:

async function fetchWithTimeout<T>(
  url: string,
  options: RequestInit = {},
  timeoutMs: number = 10000  // 10 seconds default
): Promise<T> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });

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

    return await response.json();
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      throw new Error('Request timeout');
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

// Usage:
try {
  const data = await fetchWithTimeout<User>('/api/user', {}, 5000);
} catch (error) {
  if (error.message === 'Request timeout') {
    // Handle timeout specifically
  }
}

Retry with Exponential Backoff

For unreliable networks, implement automatic retries:

async function fetchWithRetry<T>(
  url: string,
  options: RequestInit = {},
  maxRetries: number = 3,
  baseDelayMs: number = 1000
): Promise<T> {
  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      if (!response.ok) {
        // Don't retry client errors (4xx)
        if (response.status >= 400 && response.status < 500) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        // Retry server errors (5xx)
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      return await response.json();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error('Unknown error');

      // Don't retry if we've exhausted attempts
      if (attempt === maxRetries) {
        break;
      }

      // Don't retry client errors
      if (lastError.message.includes('status: 4')) {
        break;
      }

      // Exponential backoff: 1s, 2s, 4s, 8s...
      const delay = baseDelayMs * Math.pow(2, attempt);
      console.log(`Retry ${attempt + 1}/${maxRetries} in ${delay}ms...`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw lastError;
}

// Usage:
const data = await fetchWithRetry<User[]>('/api/users', {}, 3, 1000);

Detecting Network State

Before making requests, check if the device has network connectivity:

import NetInfo from '@react-native-community/netinfo';
import { useEffect, useState } from 'react';

// Hook to track network status
export function useNetworkStatus() {
  const [isConnected, setIsConnected] = useState<boolean | null>(null);
  const [connectionType, setConnectionType] = useState<string | null>(null);

  useEffect(() => {
    // Subscribe to network state updates
    const unsubscribe = NetInfo.addEventListener(state => {
      setIsConnected(state.isConnected);
      setConnectionType(state.type);
    });

    return () => unsubscribe();
  }, []);

  return { isConnected, connectionType };
}

// Usage in a component:
function DataFetcher() {
  const { isConnected } = useNetworkStatus();
  const [shouldFetch, setShouldFetch] = useState(false);

  useEffect(() => {
    // Only fetch when connected
    if (isConnected) {
      setShouldFetch(true);
    }
  }, [isConnected]);

  if (isConnected === false) {
    return (
      <View style={styles.offline}>
        <Text>📵 You're offline</Text>
        <Text>Connect to the internet to load data</Text>
      </View>
    );
  }

  // ... rest of component
}

⚠️ Install NetInfo

NetInfo is a community package. Install it with:

npx expo install @react-native-community/netinfo

Combining Timeout, Retry, and Network Check

Here's a production-ready fetch wrapper that combines everything:

// utils/robustFetch.ts
import NetInfo from '@react-native-community/netinfo';

interface FetchConfig {
  timeoutMs?: number;
  maxRetries?: number;
  retryDelayMs?: number;
  checkNetwork?: boolean;
}

const defaultConfig: Required<FetchConfig> = {
  timeoutMs: 15000,
  maxRetries: 3,
  retryDelayMs: 1000,
  checkNetwork: true,
};

export async function robustFetch<T>(
  url: string,
  options: RequestInit = {},
  config: FetchConfig = {}
): Promise<T> {
  const { timeoutMs, maxRetries, retryDelayMs, checkNetwork } = {
    ...defaultConfig,
    ...config,
  };

  // Check network connectivity first
  if (checkNetwork) {
    const netState = await NetInfo.fetch();
    if (!netState.isConnected) {
      throw new Error('No internet connection');
    }
  }

  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal,
      });

      clearTimeout(timeoutId);

      if (!response.ok) {
        // Don't retry 4xx errors
        if (response.status >= 400 && response.status < 500) {
          const errorData = await response.json().catch(() => ({}));
          throw new ApiError(
            errorData.message || `HTTP ${response.status}`,
            response.status
          );
        }
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      return await response.json();
    } catch (error) {
      clearTimeout(timeoutId);

      if (error instanceof Error && error.name === 'AbortError') {
        lastError = new Error('Request timeout');
      } else {
        lastError = error instanceof Error ? error : new Error('Unknown error');
      }

      // Don't retry certain errors
      if (
        lastError instanceof ApiError ||
        lastError.message === 'No internet connection'
      ) {
        throw lastError;
      }

      if (attempt < maxRetries) {
        const delay = retryDelayMs * Math.pow(2, attempt);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }

  throw lastError;
}

Hands-On Exercises

Exercise 1: User List with Loading States

Build a screen that fetches and displays a list of users from the JSONPlaceholder API.

Requirements:

  • Fetch users from https://jsonplaceholder.typicode.com/users
  • Show a loading spinner while fetching
  • Display an error message with a retry button if the fetch fails
  • Use FlatList to render the users efficiently
  • Implement pull-to-refresh
Show Hint

Start with the Loading State Trinity pattern. Create three state variables for loading, error, and data. Remember to set loading to true before fetching and handle both success and error cases.

Show Solution
import { useState, useEffect, useCallback } from 'react';
import { 
  View, 
  Text, 
  FlatList, 
  StyleSheet, 
  ActivityIndicator,
  Pressable,
  RefreshControl
} from 'react-native';

interface User {
  id: number;
  name: string;
  email: string;
  company: {
    name: string;
  };
}

export default function UserListScreen() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [isRefreshing, setIsRefreshing] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const fetchUsers = useCallback(async (isRefresh = false) => {
    if (isRefresh) {
      setIsRefreshing(true);
    } else {
      setIsLoading(true);
    }
    setError(null);

    try {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/users'
      );
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const data = await response.json();
      setUsers(data);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
    } finally {
      setIsLoading(false);
      setIsRefreshing(false);
    }
  }, []);

  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (isLoading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#667eea" />
        <Text style={styles.loadingText}>Loading users...</Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.centered}>
        <Text style={styles.errorEmoji}>😕</Text>
        <Text style={styles.errorText}>Failed to load users</Text>
        <Text style={styles.errorMessage}>{error.message}</Text>
        <Pressable 
          style={styles.retryButton} 
          onPress={() => fetchUsers()}
        >
          <Text style={styles.retryText}>Try Again</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <FlatList
      data={users}
      keyExtractor={item => item.id.toString()}
      contentContainerStyle={styles.list}
      refreshControl={
        <RefreshControl
          refreshing={isRefreshing}
          onRefresh={() => fetchUsers(true)}
          colors={['#667eea']}
        />
      }
      renderItem={({ item }) => (
        <View style={styles.userCard}>
          <Text style={styles.userName}>{item.name}</Text>
          <Text style={styles.userEmail}>{item.email}</Text>
          <Text style={styles.userCompany}>{item.company.name}</Text>
        </View>
      )}
    />
  );
}

const styles = StyleSheet.create({
  centered: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  loadingText: {
    marginTop: 12,
    fontSize: 16,
    color: '#666',
  },
  errorEmoji: {
    fontSize: 48,
    marginBottom: 12,
  },
  errorText: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 8,
  },
  errorMessage: {
    fontSize: 14,
    color: '#666',
    marginBottom: 20,
  },
  retryButton: {
    backgroundColor: '#667eea',
    paddingHorizontal: 24,
    paddingVertical: 12,
    borderRadius: 8,
  },
  retryText: {
    color: 'white',
    fontWeight: '600',
  },
  list: {
    padding: 16,
  },
  userCard: {
    backgroundColor: 'white',
    padding: 16,
    borderRadius: 8,
    marginBottom: 12,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 2,
  },
  userName: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 4,
  },
  userEmail: {
    fontSize: 14,
    color: '#667eea',
    marginBottom: 4,
  },
  userCompany: {
    fontSize: 14,
    color: '#666',
  },
});

Exercise 2: Post Detail with AbortController

Create a Post Detail screen that properly cancels requests when unmounting.

Requirements:

  • Accept a postId prop
  • Fetch post from https://jsonplaceholder.typicode.com/posts/{id}
  • Cancel the request if the component unmounts before it completes
  • Cancel the previous request when postId changes
  • Handle AbortError differently from other errors
Show Hint

Create an AbortController inside useEffect. Pass its signal to fetch. In the cleanup function, call abort(). Check for error.name === 'AbortError' and don't show it to users.

Show Solution
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';

interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

interface PostDetailProps {
  postId: number;
}

export default function PostDetail({ postId }: PostDetailProps) {
  const [post, setPost] = useState<Post | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    // Create abort controller for this effect instance
    const abortController = new AbortController();

    const fetchPost = async () => {
      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/posts/${postId}`,
          { signal: abortController.signal }
        );

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

        const data = await response.json();
        setPost(data);
      } catch (err) {
        // Check if this was an intentional abort
        if (err instanceof Error && err.name === 'AbortError') {
          console.log(`Fetch for post ${postId} was aborted`);
          return; // Don't update state for aborted requests
        }
        setError(err instanceof Error ? err : new Error('Unknown error'));
      } finally {
        // Only update loading state if not aborted
        if (!abortController.signal.aborted) {
          setIsLoading(false);
        }
      }
    };

    fetchPost();

    // Cleanup: abort the request when effect re-runs or unmounts
    return () => {
      abortController.abort();
    };
  }, [postId]); // Re-fetch when postId changes

  if (isLoading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#667eea" />
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.centered}>
        <Text style={styles.errorText}>Error: {error.message}</Text>
      </View>
    );
  }

  if (!post) {
    return (
      <View style={styles.centered}>
        <Text>Post not found</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>{post.title}</Text>
      <Text style={styles.body}>{post.body}</Text>
      <Text style={styles.meta}>Post #{post.id} by User #{post.userId}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  centered: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  container: {
    flex: 1,
    padding: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 16,
  },
  body: {
    fontSize: 16,
    lineHeight: 24,
    color: '#333',
    marginBottom: 20,
  },
  meta: {
    fontSize: 14,
    color: '#666',
    fontStyle: 'italic',
  },
  errorText: {
    color: 'red',
  },
});

Exercise 3: Create Your Own useFetch Hook

Build a reusable useFetch hook from scratch based on what you've learned.

Requirements:

  • Accept a URL string as the first argument
  • Return data, isLoading, error, and refetch
  • Automatically fetch on mount
  • Cancel requests on unmount
  • Provide a refetch function for manual refreshes
Show Hint

Use useState for the three states, useEffect for the automatic fetch with cleanup, and useCallback for the refetch function. The refetch function should be stable (not recreated on every render).

Show Solution
import { useState, useEffect, useCallback } from 'react';

interface UseFetchResult<T> {
  data: T | null;
  isLoading: boolean;
  error: Error | null;
  refetch: () => void;
}

export function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  // Track if we should trigger a refetch
  const [fetchTrigger, setFetchTrigger] = useState(0);

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

    const fetchData = async () => {
      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch(url, {
          signal: abortController.signal,
        });

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

        const result = await response.json();
        setData(result);
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          return;
        }
        setError(err instanceof Error ? err : new Error('Unknown error'));
      } finally {
        if (!abortController.signal.aborted) {
          setIsLoading(false);
        }
      }
    };

    fetchData();

    return () => {
      abortController.abort();
    };
  }, [url, fetchTrigger]);

  // Stable refetch function
  const refetch = useCallback(() => {
    setFetchTrigger(prev => prev + 1);
  }, []);

  return { data, isLoading, error, refetch };
}

// Usage example:
// const { data, isLoading, error, refetch } = useFetch<User[]>(
//   'https://jsonplaceholder.typicode.com/users'
// );

Summary

In this lesson, you learned how to fetch data in React Native—a skill that transfers almost directly from web development. Let's recap the key points:

🎯 Key Takeaways

  • fetch() works identically in React Native and web—same API, same patterns
  • Always handle three states: loading, success, and error (the Loading State Trinity)
  • Use AbortController to cancel requests when components unmount or dependencies change
  • Check for AbortError and don't show it to users—it's an intentional cancellation
  • Provide user-friendly error messages based on error type (network, HTTP status, parse)
  • Use environment variables via app.config.js and expo-constants for API URLs
  • Build reusable hooks to avoid repeating boilerplate across components
  • Add production patterns like timeouts, retries, and network state detection for robust apps

In the next lesson, we'll explore data fetching libraries like React Query and SWR that handle caching, background refetching, and much more—building on these fundamentals to create even more powerful data management solutions.