React Native REST API Integration: Fetch, Post, and Optimize Requests

Most production React Native apps talk to at least one REST API: auth, profiles, feeds, search, orders, payments, media, notifications, analytics, or admin workflows. The important decision is not whether you use Fetch or Axios. The important decision is whether every request goes through a typed, observable, cancellable, and secure API layer.
Quick Answer
To integrate a REST API in React Native, create one API client, read the base URL from environment-specific config, use HTTPS, keep secrets on the backend, type response models, normalize errors, cancel stale requests, paginate lists, handle offline and retry states, and test on real iOS and Android devices. Use Fetch for a small dependency surface or Axios when your app needs interceptors, upload progress, or shared transforms.
For debugging, see Debugging Network Requests in React Native. For list performance, see React Native FlatList optimization.
Design One API Boundary
Avoid scattering raw fetch or axios.get calls across screens. A shared client gives you one place for:
- base URL selection;
- auth header injection;
- request IDs;
- timeouts and cancellation;
- error normalization;
- JSON parsing;
- retry policy;
- redacted logging;
- test mocks.
Keep app secrets out of this layer. Public API base URLs and public analytics identifiers can live in mobile config. Payment secrets, private API keys, service role keys, and admin credentials must stay on a backend.
Build a Typed Fetch Client
Fetch is built into React Native and is a good default when you want a small, explicit client.
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com';
type ApiErrorBody = {
message?: string;
code?: string;
};
export class ApiError extends Error {
status: number;
body: ApiErrorBody | null;
constructor(status: number, body: ApiErrorBody | null) {
super(body?.message ?? `Request failed with status ${status}`);
this.status = status;
this.body = body;
}
}
export async function apiFetch<T>(
path: string,
options: RequestInit & { token?: string; timeoutMs?: number } = {},
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
options.timeoutMs ?? 15_000,
);
try {
const response = await fetch(`${API_URL}${path}`, {
...options,
signal: controller.signal,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
...options.headers,
},
});
const text = await response.text();
const body = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new ApiError(response.status, body);
}
return body as T;
} finally {
clearTimeout(timeoutId);
}
}
Use the same wrapper for GET, POST, PATCH, and DELETE. Screens should not know about header details or response parsing.
Mega Bundle Sale is ON! Get ALL of our React Native codebases at 90% OFF discount 🔥
Get the Mega BundleFetch Data in a Screen
Cancel requests when the screen unmounts or when the input changes. This prevents stale responses from updating a screen the user already left.
import { useEffect, useState } from 'react';
import { FlatList, Text, View } from 'react-native';
import { apiFetch } from './apiClient';
type Product = {
id: string;
title: string;
priceLabel: string;
};
export function ProductsScreen() {
const [products, setProducts] = useState<Product[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
async function loadProducts() {
try {
const nextProducts = await apiFetch<Product[]>('/products');
if (active) {
setProducts(nextProducts);
}
} catch (requestError) {
if (active) {
setError('Products could not be loaded.');
}
} finally {
if (active) {
setLoading(false);
}
}
}
loadProducts();
return () => {
active = false;
};
}, []);
if (loading) {
return <Text>Loading products...</Text>;
}
if (error) {
return <Text>{error}</Text>;
}
return (
<View style={{ flex: 1 }}>
<FlatList
data={products}
keyExtractor={item => item.id}
renderItem={({ item }) => <Text>{item.title} - {item.priceLabel}</Text>}
/>
</View>
);
}
For a larger app, move request state into a server-state library such as TanStack Query so caching, refetching, retries, and pagination do not become custom code in every screen.
Send POST Requests Safely
Validate input before sending it, keep server-side validation authoritative, and make duplicate submissions harmless where possible.
type CreateOrderInput = {
productId: string;
quantity: number;
};
type Order = {
id: string;
status: 'pending' | 'paid' | 'cancelled';
};
export async function createOrder(input: CreateOrderInput, token: string) {
if (input.quantity < 1) {
throw new Error('Quantity must be at least 1.');
}
return apiFetch<Order>('/orders', {
method: 'POST',
token,
body: JSON.stringify(input),
});
}
For payments, bookings, and checkout flows, use server-side idempotency and never trust the client for prices, discounts, currency, or inventory.
Use Axios When Interceptors Help
Axios is useful when you want shared interceptors, upload progress, or a familiar API across mobile and backend code.
import axios from 'axios';
export const api = axios.create({
baseURL: process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com',
timeout: 15_000,
});
api.interceptors.request.use(config => {
config.headers.set?.('x-client-platform', 'mobile');
return config;
});
api.interceptors.response.use(
response => response,
error => {
const status = error.response?.status;
const message = error.response?.data?.message ?? 'Request failed.';
return Promise.reject({ status, message, cause: error });
},
);
Do not build both Fetch and Axios clients unless you have a clear reason. One consistent path is easier to test and debug.
Handle Auth and Refresh Tokens Carefully
A secure mobile API flow usually needs:
- access tokens with short lifetimes;
- refresh handled through a secure auth provider or backend endpoint;
- logout that clears local session state and cached API data;
- server-side authorization checks for every protected resource;
- no service role keys or payment secrets in the app bundle.
For Firebase-backed apps, combine this with the Firebase production checklist, the App Check section, and your app-specific backend rules.
Paginate Lists and Normalize Errors
Long lists should request pages or cursors, not the entire dataset. Return predictable error shapes from the backend:
{
"message": "You are not allowed to view this order.",
"code": "ORDER_FORBIDDEN",
"requestId": "req_123"
}
The app can show a user-safe message while developers still get a request ID for server logs. Use this pattern for ecommerce, dating, chat, real estate, restaurant, and marketplace templates.
Production Checklist
Before shipping an API integration:
- verify every endpoint uses HTTPS;
- confirm secrets live on the backend;
- test expired auth and logout;
- test empty, loading, error, retry, and offline states;
- cancel stale requests on screen unmount;
- paginate large lists;
- profile list screens after several pages load;
- add request IDs to mobile and backend logs;
- run the release checklist.
Useful Links
- React Native Networking
- React Native DevTools
- Axios repository and docs
- AbortSignal on MDN
- TanStack Query
- Instamobile code snippets
Looking for a custom mobile application?
Our team of expert mobile developers can help you build a custom mobile app that meets your specific needs.
Get in Touch