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 axiosCliente 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.).
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
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
queryouserId, se aborta la solicitud anterior para evitar condiciones de carrera (respuestas viejas pisando las nuevas). - Reintentos:
listPostsWithRetryreintenta fallos de red/5xx, pero no reintenta cancelaciones. - Paginación:
onEndReachedcarga la siguiente página;x-total-countpermite 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
AbortControllero 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 query9) Checklist de implementación (paso a paso)
- Crear
httpClientconbaseURL,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
| Capa | Responsabilidad | Ejemplos |
|---|---|---|
| api/httpClient | Config de red y normalización de errores | baseURL, timeout, interceptores, cancelación |
| api/endpoints | Rutas y construcción de endpoints | postById(id), posts() |
| adapters | Serialización/transformación | userId → authorId |
| services | Operaciones de API orientadas a negocio | listar, buscar, paginar, detalle |
| screens | Estados asíncronos y UI | loading/error, reintentar, listas y detalle |