API Integration Patterns in React Native: RESTful Services, Caching, and Error Handling

13 min read
#React Native#API Integration#REST#Performance#Best Practices

Every mobile app talks to a backend. How you structure that communication determines reliability, performance, and maintainability. A well-designed API integration layer is invisible to users but essential to developers. This guide covers production-grade API integration patterns for React Native applications.

Key Takeaways

  • Centralized API service layer prevents fetch calls scattered throughout your codebase
  • Authentication layer manages tokens, refresh, and authorization transparently
  • Retry logic with exponential backoff handles transient network failures gracefully
  • Request caching reduces server load and improves perceived performance
  • Pagination patterns enable efficient handling of large datasets
  • Rate limiting prevents overwhelming your own API with request queues
  • Type-safe clients (TypeScript) catch API mismatch errors at compile time
  • Error handling strategies differ by status code (401 logout, 500 retry, 404 404-page)

Building an API Service Layer

Don't scatter fetch calls throughout your app. Create a centralized service.

Basic API Service

export class ApiService {
  constructor(baseURL = 'https://api.myapp.com') {
    this.baseURL = baseURL;
    this.timeout = 30000;
    this.headers = {
      'Content-Type': 'application/json',
    };
  }

  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;
    const config = {
      ...options,
      headers: {
        ...this.headers,
        ...options.headers,
      },
    };

    try {
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), this.timeout);

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

      clearTimeout(timeout);

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

      return await response.json();
    } catch (error) {
      if (error instanceof ApiError) {
        throw error;
      }
      throw new ApiError(0, error.message);
    }
  }

  get(endpoint, options) {
    return this.request(endpoint, {...options, method: 'GET'});
  }

  post(endpoint, data, options) {
    return this.request(endpoint, {
      ...options,
      method: 'POST',
      body: JSON.stringify(data),
    });
  }

  put(endpoint, data, options) {
    return this.request(endpoint, {
      ...options,
      method: 'PUT',
      body: JSON.stringify(data),
    });
  }

  delete(endpoint, options) {
    return this.request(endpoint, {...options, method: 'DELETE'});
  }
}

class ApiError extends Error {
  constructor(statusCode, message) {
    super(message);
    this.statusCode = statusCode;
  }
}

export const apiService = new ApiService();

Authentication and Headers

All requests likely need authentication tokens.

Token Management

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

class AuthenticatedApiService extends ApiService {
  async getAuthHeaders() {
    const token = await AsyncStorage.getItem('authToken');
    return token ? {Authorization: `Bearer ${token}`} : {};
  }

  async request(endpoint, options = {}) {
    const authHeaders = await this.getAuthHeaders();
    options.headers = {
      ...options.headers,
      ...authHeaders,
    };

    try {
      return await super.request(endpoint, options);
    } catch (error) {
      if (error.statusCode === 401) {
        await this.handleUnauthorized();
        return this.request(endpoint, options);
      }
      throw error;
    }
  }

  async handleUnauthorized() {
    await AsyncStorage.removeItem('authToken');
    await AsyncStorage.removeItem('refreshToken');
    // Navigate to login screen
  }
}

Token Refresh with Retry

class RefreshableApiService extends AuthenticatedApiService {
  async request(endpoint, options = {}) {
    try {
      return await super.request(endpoint, options);
    } catch (error) {
      if (error.statusCode === 401) {
        const refreshed = await this.refreshToken();
        if (refreshed) {
          return this.request(endpoint, options);
        }
      }
      throw error;
    }
  }

  async refreshToken() {
    try {
      const refreshToken = await AsyncStorage.getItem('refreshToken');
      const response = await fetch(
        `${this.baseURL}/auth/refresh`,
        {
          method: 'POST',
          body: JSON.stringify({refreshToken}),
          headers: {'Content-Type': 'application/json'},
        }
      );

      const data = await response.json();
      await AsyncStorage.setItem('authToken', data.accessToken);
      if (data.refreshToken) {
        await AsyncStorage.setItem('refreshToken', data.refreshToken);
      }
      return true;
    } catch {
      await this.handleUnauthorized();
      return false;
    }
  }
}

Retry Logic and Exponential Backoff

Network requests fail temporarily. Retry automatically with backoff.

class RetryableApiService extends RefreshableApiService {
  async request(endpoint, options = {}) {
    const maxRetries = options.retries || 3;
    let lastError;

    for (let i = 0; i < maxRetries; i++) {
      try {
        return await super.request(endpoint, options);
      } catch (error) {
        lastError = error;

        if (!this.shouldRetry(error, i, maxRetries)) {
          throw error;
        }

        const delay = this.getBackoffDelay(i);
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }

    throw lastError;
  }

  shouldRetry(error, attempt, maxRetries) {
    if (attempt >= maxRetries) return false;

    const retryableStatuses = [408, 429, 500, 502, 503, 504];
    const isRetryableStatus = retryableStatuses.includes(error.statusCode);
    const isNetworkError = error.statusCode === 0;

    return isRetryableStatus || isNetworkError;
  }

  getBackoffDelay(attempt) {
    const baseDelay = 1000;
    const maxDelay = 30000;
    const delay = baseDelay * Math.pow(2, attempt);
    return Math.min(delay, maxDelay);
  }
}

Request Caching

Cache GET requests to reduce server load and improve performance.

class CachedApiService extends RetryableApiService {
  constructor(baseURL) {
    super(baseURL);
    this.cache = new Map();
    this.cacheExpiry = new Map();
  }

  getCacheKey(endpoint, options) {
    return `${endpoint}:${JSON.stringify(options.params || {})}`;
  }

  isCacheValid(key) {
    const expiry = this.cacheExpiry.get(key);
    if (!expiry) return false;
    return Date.now() < expiry;
  }

  async get(endpoint, options = {}) {
    const cacheKey = this.getCacheKey(endpoint, options);
    const cacheDuration = options.cacheDuration || 5 * 60 * 1000;

    if (this.cache.has(cacheKey) && this.isCacheValid(cacheKey)) {
      console.log(`Cache hit: ${endpoint}`);
      return this.cache.get(cacheKey);
    }

    const data = await super.get(endpoint, options);

    this.cache.set(cacheKey, data);
    this.cacheExpiry.set(cacheKey, Date.now() + cacheDuration);

    return data;
  }

  clearCache(pattern) {
    if (!pattern) {
      this.cache.clear();
      this.cacheExpiry.clear();
      return;
    }

    for (const [key] of this.cache) {
      if (key.includes(pattern)) {
        this.cache.delete(key);
        this.cacheExpiry.delete(key);
      }
    }
  }
}

Pagination Patterns

Handle large datasets efficiently.

export const usePaginatedData = (endpoint, pageSize = 20) => {
  const [data, setData] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const fetchPage = useCallback(async (pageNum) => {
    if (!hasMore || loading) return;

    setLoading(true);
    try {
      const response = await apiService.get(endpoint, {
        params: {
          page: pageNum,
          limit: pageSize,
        },
      });

      if (pageNum === 1) {
        setData(response.items);
      } else {
        setData((prev) => [...prev, ...response.items]);
      }

      setHasMore(response.hasMore);
      setPage(pageNum);
    } catch (error) {
      console.error('Pagination error:', error);
    } finally {
      setLoading(false);
    }
  }, [endpoint, pageSize, hasMore, loading]);

  useEffect(() => {
    fetchPage(1);
  }, [endpoint]);

  return {
    data,
    page,
    loading,
    hasMore,
    loadMore: () => fetchPage(page + 1),
    refresh: () => fetchPage(1),
  };
};

Rate Limiting

Prevent overwhelming server and hitting rate limits.

class RateLimitedApiService extends CachedApiService {
  constructor(baseURL) {
    super(baseURL);
    this.requestQueue = [];
    this.requestTimes = [];
    this.maxRequests = 100;
    this.timeWindow = 60 * 1000;
    this.processing = false;
  }

  async request(endpoint, options = {}) {
    return new Promise((resolve, reject) => {
      this.requestQueue.push({
        endpoint,
        options,
        resolve,
        reject,
      });

      this.processQueue();
    });
  }

  async processQueue() {
    if (this.processing || this.requestQueue.length === 0) return;

    this.processing = true;

    while (this.requestQueue.length > 0) {
      this.cleanupOldRequests();

      if (this.requestTimes.length >= this.maxRequests) {
        const oldestTime = this.requestTimes[0];
        const waitTime = oldestTime + this.timeWindow - Date.now();

        if (waitTime > 0) {
          await new Promise((resolve) => setTimeout(resolve, waitTime));
          continue;
        }
      }

      const {endpoint, options, resolve, reject} = this.requestQueue.shift();

      try {
        const result = await super.request(endpoint, options);
        this.requestTimes.push(Date.now());
        resolve(result);
      } catch (error) {
        reject(error);
      }
    }

    this.processing = false;
  }

  cleanupOldRequests() {
    const cutoff = Date.now() - this.timeWindow;
    this.requestTimes = this.requestTimes.filter((time) => time > cutoff);
  }
}

Request Cancellation

Cancel in-flight requests when components unmount.

export const useCancellableApi = () => {
  const abortControllersRef = useRef(new Map());

  const request = useCallback(
    async (key, requestFn) => {
      const controller = new AbortController();
      abortControllersRef.current.set(key, controller);

      try {
        return await requestFn(controller.signal);
      } finally {
        abortControllersRef.current.delete(key);
      }
    },
    []
  );

  const cancel = useCallback((key) => {
    const controller = abortControllersRef.current.get(key);
    if (controller) {
      controller.abort();
    }
  }, []);

  const cancelAll = useCallback(() => {
    abortControllersRef.current.forEach((controller) => {
      controller.abort();
    });
    abortControllersRef.current.clear();
  }, []);

  useEffect(() => {
    return () => cancelAll();
  }, [cancelAll]);

  return {request, cancel, cancelAll};
};

Type-Safe API Clients (TypeScript)

Leverage TypeScript for API safety.

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

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

class TypedApiService extends RateLimitedApiService {
  async getUser(userId: string): Promise<User> {
    return this.get(`/users/${userId}`);
  }

  async createUser(user: Omit<User, 'id'>): Promise<User> {
    return this.post('/users', user);
  }

  async updateUser(
    userId: string,
    updates: Partial<User>
  ): Promise<User> {
    return this.put(`/users/${userId}`, updates);
  }

  async deleteUser(userId: string): Promise<void> {
    await this.delete(`/users/${userId}`);
  }
}

export const typedApi = new TypedApiService();

GraphQL Integration

Some apps use GraphQL instead of REST.

export class GraphQLClient {
  constructor(endpoint) {
    this.endpoint = endpoint;
  }

  async query(query, variables = {}) {
    const response = await fetch(this.endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${await getToken()}`,
      },
      body: JSON.stringify({
        query,
        variables,
      }),
    });

    const result = await response.json();

    if (result.errors) {
      throw new Error(result.errors[0].message);
    }

    return result.data;
  }
}

const USER_QUERY = `
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      email
      name
    }
  }
`;

const user = await graphQLClient.query(USER_QUERY, {id: '123'});

Error Handling Strategy

Different errors require different responses.

export const handleApiError = (error, context) => {
  if (error.statusCode === 401) {
    return {action: 'LOGOUT', message: 'Session expired'};
  }

  if (error.statusCode === 403) {
    return {action: 'SHOW_ERROR', message: 'Permission denied'};
  }

  if (error.statusCode === 404) {
    return {action: 'NAVIGATE', target: 'NotFound'};
  }

  if (error.statusCode >= 500) {
    return {
      action: 'SHOW_RETRY',
      message: 'Server error. Please try again.',
    };
  }

  if (error.statusCode === 0) {
    return {
      action: 'SHOW_OFFLINE',
      message: 'Check your internet connection',
    };
  }

  return {action: 'SHOW_ERROR', message: error.message};
};

Best Practices

Architecture:

  • Centralize API logic in a service layer
  • Separate concerns (auth, caching, retry, rate limiting)
  • Use dependency injection for flexibility
  • Test API logic independently from UI

Performance:

  • Cache GET requests aggressively
  • Implement pagination for large datasets
  • Cancel requests when components unmount
  • Monitor network activity

Reliability:

  • Implement exponential backoff for retries
  • Handle token refresh gracefully
  • Validate responses (even from trusted APIs)
  • Provide offline fallbacks where possible

Security:

  • Never hardcode API keys
  • Use environment variables for endpoints
  • Validate SSL certificates
  • Sanitize error messages (don't expose internals)

Developer Experience:

  • Type-safe clients (TypeScript)
  • Clear error messages
  • Consistent naming conventions
  • Mock API for testing

Well-designed API integration is the foundation of reliable mobile apps. Invest in building robust service layers early—it pays dividends in stability, performance, and developer happiness.


Let's bring your app idea to life

I specialize in mobile and backend development.

Share this article

Related Articles