Consumo de APIs y manejo de datos asíncronos en React Native

Capítulo 5

Tiempo estimado de lectura: 12 minutos

+ Ejercicio

Objetivo y enfoque

En este capítulo conectarás tu app a servicios HTTP y aprenderás a manejar datos asíncronos de forma robusta: cliente HTTP, endpoints, serialización, paginación, filtros y búsquedas. Además, implementarás estados loading/success/error, reintentos, cancelación de solicitudes y mensajes de error en UI. La idea central es separar responsabilidades: una capa de red (cliente), una capa de servicios (casos de uso de API) y adaptadores (mapeo de DTOs a modelos de dominio) para terminar integrando todo en pantallas de lista y detalle con datos reales.

Arquitectura recomendada: cliente + servicios + adaptadores

Una estructura simple y escalable:

src/  api/    httpClient.ts    endpoints.ts  services/    postsService.ts  adapters/    postAdapter.ts  models/    Post.ts  screens/    PostsListScreen.tsx    PostDetailScreen.tsx
  • api/httpClient: configura baseURL, headers, timeouts, interceptores, cancelación.
  • api/endpoints: centraliza rutas y query params.
  • services: funciones orientadas a negocio (listar, buscar, detalle).
  • adapters: serialización/deserialización (DTO → modelo).
  • screens: consume servicios y muestra estados asíncronos.

1) Configuración del cliente HTTP

Instalación

Usaremos axios por su soporte de interceptores, timeouts y cancelación.

npm i axios

Cliente con configuración base

Crea src/api/httpClient.ts:

import axios from 'axios';

export const http = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com',
  timeout: 15000,
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
});

http.interceptors.response.use(
  (response) => response,
  (error) => {
    // Normaliza el error para que la UI no dependa de Axios
    const normalized = {
      status: error?.response?.status ?? null,
      message:
        error?.response?.data?.message ??
        error?.message ??
        'Error de red inesperado',
      data: error?.response?.data ?? null,
      isCanceled: axios.isCancel?.(error) ?? false,
    };
    return Promise.reject(normalized);
  }
);

Con esto, cualquier pantalla recibirá un error consistente (status, message, etc.).

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

Cancelación de solicitudes

Axios soporta AbortController. Lo usaremos desde servicios o pantallas para cancelar al desmontar o al cambiar filtros.

const controller = new AbortController();
await http.get('/posts', { signal: controller.signal });
controller.abort();

2) Manejo de endpoints y query params

Centraliza rutas en src/api/endpoints.ts para evitar strings duplicados:

export const endpoints = {
  posts: () => '/posts',
  postById: (id: number) => `/posts/${id}`,
  commentsByPost: (postId: number) => `/posts/${postId}/comments`,
};

Para filtros/búsquedas/paginación, usa params (Axios serializa query params):

http.get(endpoints.posts(), {
  params: { _page: 1, _limit: 10, q: 'react' },
});

3) Modelos, DTOs y adaptadores (serialización)

La API devuelve DTOs (forma externa). Tu app debería trabajar con modelos internos (forma estable). Esto te protege si el backend cambia nombres o tipos.

Modelo de dominio

src/models/Post.ts:

export type Post = {
  id: number;
  title: string;
  body: string;
  authorId: number;
};

Adapter DTO → modelo

src/adapters/postAdapter.ts:

import { Post } from '../models/Post';

type PostDTO = {
  id: number;
  title: string;
  body: string;
  userId: number;
};

export const postAdapter = {
  fromDTO(dto: PostDTO): Post {
    return {
      id: dto.id,
      title: dto.title,
      body: dto.body,
      authorId: dto.userId,
    };
  },
};

Si mañana el backend cambia userId por author_id, solo ajustas el adapter.

4) Capa de servicios: listar, buscar, paginar y detalle

Implementa servicios que devuelvan modelos ya adaptados y oculten detalles de red.

Servicio de posts

src/services/postsService.ts:

import { http } from '../api/httpClient';
import { endpoints } from '../api/endpoints';
import { postAdapter } from '../adapters/postAdapter';
import { Post } from '../models/Post';

export type ListPostsParams = {
  page?: number;
  limit?: number;
  q?: string; // búsqueda
  userId?: number; // filtro
};

export async function listPosts(
  params: ListPostsParams,
  options?: { signal?: AbortSignal }
): Promise<{ items: Post[]; total?: number }> {
  const res = await http.get(endpoints.posts(), {
    params: {
      _page: params.page,
      _limit: params.limit,
      q: params.q,
      userId: params.userId,
    },
    signal: options?.signal,
  });

  const items = (res.data as any[]).map(postAdapter.fromDTO);

  // JSONPlaceholder expone total en header x-total-count cuando usas _limit
  const totalHeader = res.headers?.['x-total-count'];
  const total = totalHeader ? Number(totalHeader) : undefined;

  return { items, total };
}

export async function getPostById(
  id: number,
  options?: { signal?: AbortSignal }
): Promise<Post> {
  const res = await http.get(endpoints.postById(id), {
    signal: options?.signal,
  });
  return postAdapter.fromDTO(res.data);
}

Reintentos con backoff (cuando tiene sentido)

Reintentar no siempre es buena idea (por ejemplo, errores 400). Úsalo para fallos de red o 5xx. Crea un helper simple:

type RetryOptions = {
  retries: number;
  baseDelayMs?: number;
  shouldRetry?: (err: any) => boolean;
};

export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions): Promise<T> {
  const baseDelayMs = opts.baseDelayMs ?? 400;
  let attempt = 0;

  while (true) {
    try {
      return await fn();
    } catch (err: any) {
      attempt++;
      const canRetry = opts.shouldRetry ? opts.shouldRetry(err) : true;
      if (!canRetry || attempt > opts.retries) throw err;

      const delay = baseDelayMs * Math.pow(2, attempt - 1);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}

Ejemplo de uso en un servicio:

import { withRetry } from '../utils/withRetry';

export async function listPostsWithRetry(params: ListPostsParams, signal?: AbortSignal) {
  return withRetry(
    () => listPosts(params, { signal }),
    {
      retries: 2,
      shouldRetry: (err) => {
        if (err?.isCanceled) return false;
        if (err?.status == null) return true; // sin status: red
        return err.status >= 500;
      },
    }
  );
}

5) Estados asíncronos en UI: loading/success/error

Un patrón práctico es modelar el estado de la pantalla con una unión discriminada:

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string; statusCode?: number | null };

Esto evita combinaciones inválidas (por ejemplo, loading=true y error a la vez).

6) Pantalla de lista: paginación, filtros, búsqueda y cancelación

Implementaremos una lista con FlatList, búsqueda con TextInput, paginación incremental y control de errores. Usaremos JSONPlaceholder para datos reales.

Componente de item (simple)

import React from 'react';
import { Pressable, Text, View } from 'react-native';
import { Post } from '../models/Post';

export function PostRow({ post, onPress }: { post: Post; onPress: () => void }) {
  return (
    <Pressable onPress={onPress} style={{ padding: 12 }}>
      <Text style={{ fontWeight: '700' }} numberOfLines={1}>{post.title}</Text>
      <Text numberOfLines={2} style={{ opacity: 0.8 }}>{post.body}</Text>
    </Pressable>
  );
}

Pantalla de lista con control asíncrono

src/screens/PostsListScreen.tsx:

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, FlatList, Text, TextInput, View, Button } from 'react-native';
import { listPostsWithRetry } from '../services/postsService';
import { Post } from '../models/Post';
import { PostRow } from '../components/PostRow';

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string; statusCode?: number | null };

export function PostsListScreen({ navigation }: any) {
  const [query, setQuery] = useState('');
  const [userId, setUserId] = useState<number | undefined>(undefined);

  const [page, setPage] = useState(1);
  const limit = 10;

  const [items, setItems] = useState<Post[]>([]);
  const [total, setTotal] = useState<number | undefined>(undefined);

  const [state, setState] = useState<AsyncState<null>>({ status: 'idle' });
  const [isFetchingMore, setIsFetchingMore] = useState(false);

  const controllerRef = useRef<AbortController | null>(null);

  const canLoadMore = useMemo(() => {
    if (total == null) return true;
    return items.length < total;
  }, [items.length, total]);

  async function fetchFirstPage() {
    controllerRef.current?.abort();
    const controller = new AbortController();
    controllerRef.current = controller;

    setState({ status: 'loading' });
    setPage(1);

    try {
      const res = await listPostsWithRetry(
        { page: 1, limit, q: query.trim() || undefined, userId },
        controller.signal
      );
      setItems(res.items);
      setTotal(res.total);
      setState({ status: 'success', data: null });
    } catch (err: any) {
      if (err?.isCanceled) return;
      setState({ status: 'error', message: err?.message ?? 'No se pudo cargar', statusCode: err?.status });
    }
  }

  async function fetchNextPage() {
    if (!canLoadMore || isFetchingMore) return;

    const next = page + 1;
    setIsFetchingMore(true);

    // No cancelamos la página actual si el usuario hace scroll; solo evitamos duplicados.
    const controller = new AbortController();

    try {
      const res = await listPostsWithRetry(
        { page: next, limit, q: query.trim() || undefined, userId },
        controller.signal
      );
      setItems((prev) => [...prev, ...res.items]);
      setTotal(res.total);
      setPage(next);
    } catch (err: any) {
      if (!err?.isCanceled) {
        // Error no bloqueante: mostramos mensaje y permitimos reintentar con scroll o botón
      }
    } finally {
      setIsFetchingMore(false);
    }
  }

  useEffect(() => {
    fetchFirstPage();
    return () => controllerRef.current?.abort();
  }, [query, userId]);

  return (
    <View style={{ flex: 1 }}>
      <View style={{ padding: 12, gap: 8 }}>
        <TextInput
          value={query}
          onChangeText={setQuery}
          placeholder="Buscar por texto..."
          autoCapitalize="none"
          style={{ borderWidth: 1, borderColor: '#ddd', padding: 10, borderRadius: 8 }}
        />

        <View style={{ flexDirection: 'row', gap: 8 }}>
          <Button title="Filtrar userId=1" onPress={() => setUserId(1)} />
          <Button title="Quitar filtro" onPress={() => setUserId(undefined)} />
        </View>
      </View>

      {state.status === 'loading' && (
        <View style={{ padding: 16 }}>
          <ActivityIndicator />
          <Text style={{ marginTop: 8 }}>Cargando posts...</Text>
        </View>
      )}

      {state.status === 'error' && (
        <View style={{ padding: 16, gap: 8 }}>
          <Text style={{ color: 'crimson', fontWeight: '700' }}>Error</Text>
          <Text>{state.message}</Text>
          <Button title="Reintentar" onPress={fetchFirstPage} />
        </View>
      )}

      {(state.status === 'success' || state.status === 'idle') && (
        <FlatList
          data={items}
          keyExtractor={(item) => String(item.id)}
          renderItem={({ item }) => (
            <PostRow
              post={item}
              onPress={() => navigation.navigate('PostDetail', { id: item.id })}
            />
          )}
          ItemSeparatorComponent={() => <View style={{ height: 1, backgroundColor: '#eee' }} />}
          onEndReachedThreshold={0.4}
          onEndReached={fetchNextPage}
          ListFooterComponent={() =>
            isFetchingMore ? (
              <View style={{ padding: 16 }}>
                <ActivityIndicator />
                <Text style={{ marginTop: 8 }}>Cargando más...</Text>
              </View>
            ) : !canLoadMore ? (
              <View style={{ padding: 16 }}>
                <Text style={{ opacity: 0.7 }}>No hay más resultados</Text>
              </View>
            ) : null
          }
        />
      )}
    </View>
  );
}

Notas clave de la lista

  • Cancelación: al cambiar query o userId, se aborta la solicitud anterior para evitar condiciones de carrera (respuestas viejas pisando las nuevas).
  • Reintentos: listPostsWithRetry reintenta fallos de red/5xx, pero no reintenta cancelaciones.
  • Paginación: onEndReached carga la siguiente página; x-total-count permite saber si hay más.
  • Errores en UI: el primer fetch muestra un bloque de error con botón “Reintentar”. Los errores al paginar se pueden tratar como no bloqueantes (opcionalmente mostrar un banner/toast).

7) Pantalla de detalle: solicitud única + manejo de errores

Ahora consume un endpoint por id y muestra estados asíncronos. También cancelaremos al desmontar.

import React, { useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Button, ScrollView, Text, View } from 'react-native';
import { getPostById } from '../services/postsService';
import { Post } from '../models/Post';

type AsyncState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string; statusCode?: number | null };

export function PostDetailScreen({ route }: any) {
  const { id } = route.params as { id: number };

  const [state, setState] = useState<AsyncState<Post>>({ status: 'loading' });
  const controllerRef = useRef<AbortController | null>(null);

  async function load() {
    controllerRef.current?.abort();
    const controller = new AbortController();
    controllerRef.current = controller;

    setState({ status: 'loading' });
    try {
      const post = await getPostById(id, { signal: controller.signal });
      setState({ status: 'success', data: post });
    } catch (err: any) {
      if (err?.isCanceled) return;
      setState({ status: 'error', message: err?.message ?? 'No se pudo cargar', statusCode: err?.status });
    }
  }

  useEffect(() => {
    load();
    return () => controllerRef.current?.abort();
  }, [id]);

  if (state.status === 'loading') {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator />
        <Text style={{ marginTop: 8 }}>Cargando detalle...</Text>
      </View>
    );
  }

  if (state.status === 'error') {
    return (
      <View style={{ flex: 1, padding: 16, gap: 8 }}>
        <Text style={{ color: 'crimson', fontWeight: '700' }}>Error</Text>
        <Text>{state.message}</Text>
        <Button title="Reintentar" onPress={load} />
      </View>
    );
  }

  const post = state.data;
  return (
    <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>
      <Text style={{ fontSize: 20, fontWeight: '800' }}>{post.title}</Text>
      <Text style={{ opacity: 0.7 }}>Autor: {post.authorId}</Text>
      <Text style={{ fontSize: 16, lineHeight: 22 }}>{post.body}</Text>
    </ScrollView>
  );
}

8) Control de errores y mensajes de UI (patrones prácticos)

Mapeo de errores a mensajes amigables

Tu interceptor ya normaliza, pero puedes traducir por status:

export function toUserMessage(err: any) {
  if (err?.isCanceled) return null;
  if (err?.status === 401) return 'Tu sesión expiró. Inicia sesión nuevamente.';
  if (err?.status === 403) return 'No tienes permisos para realizar esta acción.';
  if (err?.status === 404) return 'No se encontró el recurso solicitado.';
  if (err?.status >= 500) return 'El servidor tuvo un problema. Intenta más tarde.';
  if (err?.status == null) return 'Sin conexión. Revisa tu internet e intenta de nuevo.';
  return err?.message ?? 'Ocurrió un error.';
}

En pantallas, usa este mensaje para mostrar un bloque de error o un banner.

Evitar condiciones de carrera

  • Problema: el usuario escribe rápido en búsqueda; la respuesta de “re” llega después de “rea” y pisa resultados.
  • Solución: abortar la solicitud anterior con AbortController o usar un id incremental de request y solo aceptar la última.

Debounce para búsqueda

Para no disparar requests en cada tecla, aplica debounce. Ejemplo simple con setTimeout:

const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');

useEffect(() => {
  const t = setTimeout(() => setDebounced(query), 350);
  return () => clearTimeout(t);
}, [query]);

// Usa debounced en tu efecto de fetch en lugar de query

9) Checklist de implementación (paso a paso)

  • Crear httpClient con baseURL, timeout, headers e interceptor de errores normalizado.
  • Centralizar rutas en endpoints.
  • Definir modelos internos (Post) y adaptadores DTO→modelo.
  • Crear servicios (listPosts, getPostById) que devuelvan modelos ya adaptados.
  • Agregar helper de reintentos con política (withRetry + shouldRetry).
  • En pantallas, modelar estado asíncrono (loading/success/error) y mostrar UI acorde.
  • Implementar cancelación en efectos y al cambiar filtros/búsqueda.
  • Implementar lista con paginación (onEndReached) y detalle por id.

10) Tabla rápida: qué va en cada capa

CapaResponsabilidadEjemplos
api/httpClientConfig de red y normalización de erroresbaseURL, timeout, interceptores, cancelación
api/endpointsRutas y construcción de endpointspostById(id), posts()
adaptersSerialización/transformaciónuserIdauthorId
servicesOperaciones de API orientadas a negociolistar, buscar, paginar, detalle
screensEstados asíncronos y UIloading/error, reintentar, listas y detalle

Ahora responde el ejercicio sobre el contenido:

¿Cuál es el propósito principal de cancelar una solicitud anterior cuando cambian los filtros o el texto de búsqueda en una pantalla de lista?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Al abortar la solicitud previa cuando cambian query o filtros, se evita que una respuesta vieja llegue tarde y pise los datos de una búsqueda más reciente, manteniendo consistencia en la UI.

Siguiente capítulo

Persistencia local, caché y sincronización offline en React Native

Arrow Right Icon
Portada de libro electrónico gratuitaReact Native desde Cero a App Profesional
42%

React Native desde Cero a App Profesional

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.