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).
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:
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
postIdprop - Fetch post from
https://jsonplaceholder.typicode.com/posts/{id} - Cancel the request if the component unmounts before it completes
- Cancel the previous request when
postIdchanges - 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, andrefetch - Automatically fetch on mount
- Cancel requests on unmount
- Provide a
refetchfunction 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.jsandexpo-constantsfor 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.