useFetch — Obtención simple de datos con carga, error y refetch
Receta
import { useState, useEffect, useCallback, useRef } from "react";
interface UseFetchOptions<T> {
/** Omitir la obtención inicial. Predeterminado: false */
skip?: boolean;
/** Transformar la respuesta antes de configurar datos */
transform?: (data: unknown) => T;
/** Opciones de init de fetch personalizadas (encabezados, método, etc.) */
init?: RequestInit;
/** Dependencias que desencadenan un refetch cuando cambian */
deps?: unknown[];
}
interface UseFetchReturn<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
/** Desencadenar manualmente un refetch */
refetch: () => void;
/** Cancelar la solicitud actual */
abort: () => void;
}
function useFetch<T = unknown>(
url: string | null,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const { skip = false, transform, init, deps = [] } = options;
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(!skip && url !== null);
const abortControllerRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
const fetchData = useCallback(async () => {
if (!url || skip) return;
// Cancelar cualquier solicitud en vuelo
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsLoading(true);
setError(null);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
const result = transform ? transform(json) : (json as T);
if (mountedRef.current && !controller.signal.aborted) {
setData(result);
setIsLoading(false);
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// La solicitud fue cancelada, no actualizar el estado
return;
}
if (mountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
}
}
}, [url, skip, transform, init, ...deps]);
// Obtener en montar y cuando cambien las dependencias
useEffect(() => {
mountedRef.current = true;
fetchData();
return () => {
mountedRef.current = false;
abortControllerRef.current?.abort();
};
}, [fetchData]);
const abort = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
}, []);
return { data, error, isLoading, refetch: fetchData, abort };
}Cuándo usarlo: Necesitas un hook ligero para obtener datos en casos de uso simples, prototipos, o cuando SWR/TanStack Query es demasiado pesado. Para aplicaciones de producción con almacenamiento en caché, deduplicación y revalidación, prefiere una biblioteca dedicada.
Ejemplo Práctico
"use client";
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, error, isLoading, refetch } = useFetch<User>(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ deps: [userId] }
);
if (isLoading) return <p>Cargando...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data) return null;
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
<button onClick={refetch}>Actualizar</button>
</div>
);
}
function PostList() {
const { data: posts, isLoading } = useFetch<
Array<{ id: number; title: string }>
>("https://jsonplaceholder.typicode.com/posts", {
transform: (raw) =>
(raw as Array<{ id: number; title: string }>).slice(0, 10),
});
if (isLoading) return <p>Cargando publicaciones...</p>;
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
// Omitir obtención hasta que el usuario actúe
function SearchResults() {
const [query, setQuery] = useState("");
const [searchTerm, setSearchTerm] = useState<string | null>(null);
const { data, isLoading } = useFetch<{ results: string[] }>(
searchTerm
? `https://api.example.com/search?q=${encodeURIComponent(searchTerm)}`
: null
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
/>
<button onClick={() => setSearchTerm(query)}>Buscar</button>
{isLoading && <p>Buscando...</p>}
{data?.results.map((r, i) => <p key={i}>{r}</p>)}
</div>
);
}Lo que esto demuestra:
- UserProfile: Obtiene datos del usuario por ID, refetch cuando
userIdcambia, botón de refetch manual - PostList: Transforma la respuesta para tomar solo los primeros 10 elementos
- SearchResults: Pasa
nullcomo URL para omitir la obtención hasta que el usuario envíe una búsqueda
Análisis Profundo
Cómo Funciona
- AbortController: Cada fetch crea un nuevo
AbortController. Si la URL o las dependencias cambian antes de que se complete la solicitud, la solicitud anterior se cancela para prevenir condiciones de carrera. - Patrón de URL nula: Pasar
nullcomo la URL omite la obtención completamente. Este es un patrón común para obtención condicional (similar a la obtención condicional de SWR). - Función transform: Se ejecuta antes de
setData, permitiéndote remoldear respuestas de API (filtrar, mapear, seleccionar campos) sin estado adicional. - Protección de montaje:
mountedRefpreviene actualizaciones de estado después de desmontar, evitando advertencias de React. - Manejo de AbortError: Las solicitudes canceladas lanzan una
DOMExceptionconname: "AbortError". El hook ignora silenciosamente estos para evitar estados de error falsos.
Parámetros y Valores de Retorno
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
url | string or null | — | URL de fetch, o null para omitir |
options.skip | boolean | false | Omitir la obtención |
options.transform | (data: unknown) => T | — | Transformar datos de respuesta |
options.init | RequestInit | — | Opciones de fetch (encabezados, método, cuerpo) |
options.deps | unknown[] | [] | Deps adicionales que desencadenan refetch |
| Retorno | Tipo | Descripción |
|---|---|---|
data | T or null | Datos de respuesta, o null |
error | Error or null | Error, o null |
isLoading | boolean | Si una solicitud está en vuelo |
refetch | () => void | Desencadenar manualmente un nuevo fetch |
abort | () => void | Cancelar la solicitud actual |
Variaciones
Solicitudes POST/PUT: Pasa método y cuerpo a través de init:
const { data, isLoading } = useFetch("/api/users", {
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
},
});Con reintentos: Agrega reintentos automáticos en caso de falla:
// Dentro de fetchData, envuelve en un bucle de reintento:
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, { signal: controller.signal });
// ...manejo de éxito
break;
} catch (err) {
if (attempt === maxRetries - 1) throw err;
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}Cuándo Usar Esto vs Bibliotecas
| Feature | useFetch (this hook) | SWR | TanStack Query |
|---|---|---|---|
| Tamaño del paquete | 0 KB | ~4 KB | ~13 KB |
| Almacenamiento en caché | No | Sí | Sí |
| Deduplicación | No | Sí | Sí |
| Revalidación | Solo manual | Automático | Automático |
| Actualizaciones optimistas | No | Sí | Sí |
| DevTools | No | No | Sí |
| Mejor para | Prototipos, casos simples | Complejidad media | Aplicaciones complejas |
Notas de TypeScript
- El genérico
Tpredeterminado aunknowny se infiere de la funcióntransformsi se proporciona. datase tipifica comoT | null(null antes del primer fetch exitoso).errorsiempre esError | null, envolviendo throws no-Error ennew Error(String(err)).
Gotchas
- Condiciones de carrera sin abort — Si la URL cambia rápidamente, múltiples solicitudes pueden resolverse fuera de orden. Fix:
AbortControllercancela solicitudes obsoletas; siempre úsalo. - Sin almacenamiento en caché — Cada montaje desencadena una nueva solicitud de red. Fix: Para aplicaciones que necesitan almacenamiento en caché, usa SWR o TanStack Query.
- Inestabilidad de referencia init — Pasar un nuevo objeto
initen cada renderizado desencadena refetches infinitos. Fix: MemoizainitconuseMemoo defínelo fuera del componente. - Spread deps en useCallback — El spread
...depsen la matriz de dependencias puede causar refetches inesperados si deps contienen referencias inestables. Fix: Asegúrate de que deps contengan solo primitivos o referencias estables. - Sin datos SSR — Este hook solo obtiene en el cliente. Fix: Para SSR/RSC, obtén datos en un componente de servidor y pásalo como props.
Alternativas
| Paquete | Nombre del Hook | Notas |
|---|---|---|
swr | useSWR | Stale-while-revalidate, más popular |
@tanstack/react-query | useQuery | Completo, devtools, mutaciones |
react | use() | React 19 incorporado para fetching basado en suspense |
usehooks-ts | useFetch | Implementación simple similar |
axios + custom hook | — | Usa axios para interceptores, transforma con un hook envolvente |
FAQs
¿Cómo previene useFetch condiciones de carrera cuando la URL cambia rápidamente?
- Cada llamada a
fetchDatacrea un nuevoAbortControllery cancela la solicitud anterior en vuelo. - La comprobación
controller.signal.abortedpreviene que respuestas obsoletas actualicen el estado.
¿Cómo omites el fetch inicial y lo desencadenas manualmente?
Pasa skip: true o pasa null como la URL:
// Opción 1: opción skip
const { refetch } = useFetch("/api/data", { skip: true });
// Opción 2: URL null
const { data } = useFetch(query ? `/api/search?q=${query}` : null);¿Qué hace la opción transform y cuándo la usarías?
transformse ejecuta antes desetData, permitiéndote remoldear la respuesta de la API (filtrar, mapear, seleccionar campos) sin estado adicional.- Ejemplo: rebanar una matriz grande para tomar solo los primeros 10 elementos.
¿Cómo haces una solicitud POST con useFetch?
Pasa método y cuerpo a través de la opción init:
const { data } = useFetch("/api/users", {
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
},
});Gotcha: ¿Qué pasa si pasas un nuevo objeto init en cada renderizado?
- Una nueva referencia de objeto en cada renderizado cambia la dependencia de
useCallback, desencadenando refetches infinitos. - Fix: memoiza
initconuseMemoo defínelo como una constante fuera del componente.
Gotcha: ¿Por qué el spread de deps en la matriz de dependencias useCallback a veces causa refetches inesperados?
- Si
depscontiene objetos o matrices que se recrean en cada renderizado, el cambio de referencia desencadena una nueva funciónfetchData, que desencadena el efecto de fetch. - Fix: asegúrate de que deps contengan solo primitivos o referencias estables.
¿Por qué useFetch no proporciona almacenamiento en caché, y cuándo deberías cambiar a una biblioteca?
- Cada montaje desencadena una nueva solicitud de red sin deduplicación o lógica stale-while-revalidate.
- Cambia a SWR o TanStack Query cuando necesites almacenamiento en caché, revalidación automática, actualizaciones optimistas o devtools.
¿Cómo maneja el hook AbortError diferentemente de otros errores?
- Las solicitudes canceladas lanzan una
DOMExceptionconname: "AbortError". - El hook detecta esto y regresa temprano sin actualizar el estado de error, ya que el abort fue intencional.
¿Funciona useFetch con renderizado del lado del servidor?
- No. Este hook solo obtiene en el cliente después del montaje.
- Para SSR o React Server Components, obtén datos en un componente de servidor y pásalo como props.
¿Cómo se infiere el tipo genérico T en TypeScript?
Tpredeterminado aunknowny puede proporcionarse explícitamente:useFetch<User>(url).- Si se proporciona una función
transform,Tse infiere de su tipo de retorno. datase tipifica comoT | null, dondenullrepresenta el estado pre-fetch.
¿Cuál es el tipo del error devuelto por useFetch?
errorse tipifica comoError | null.- Los throws no-Error se envuelven en
new Error(String(err))para normalizar el tipo.
¿Cómo cancelas una solicitud manualmente desde el componente consumidor?
Usa la función abort del valor de retorno:
const { data, isLoading, abort } = useFetch("/api/slow-endpoint");
return <button onClick={abort}>Cancelar</button>;Relacionado
- useDebounce — debounce search queries before fetching
- useInterval — poll an endpoint periodically
- SWR — production-grade data fetching