Inicio / Artículos / Un hook useFetch mínimo: cuándo se puede prescindir de React Query y sus limitaciones

Un hook useFetch mínimo: cuándo se puede prescindir de React Query y sus limitaciones

Crea un gancho useFetch con caché pequeño que utilice AbortController, vencimiento por TTL y un complemento useMutation, y averigua con exactitud qué funcionalidades de React Query debes renunciar.

2016 palabras

React Query (ahora TanStack Query) se ha convertido en la solución estándar para la carga de datos en React, y es un software excelente. Sin embargo, en muchos conjuntos de código casi cada llamada hace lo mismo: obtener datos, mostrar un spinner, mostrar un error y evitar volver a cargarlos. Esta guía crea un gancho useFetch pequeño y sin dependencias para ese caso, agrega caché con TTL y soporte para mutaciones, y es honesta sobre las limitaciones heredadas, para que puedas tomar decisiones según cada proyecto en lugar de seguir la configuración predeterminada.

Así es el uso típico

A menudo, el uso más común de React Query sigue un mismo patrón: una clave junto con una función que obtiene y parsea JSON.

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});

Solo se necesita carga, manejo de errores y caché. La biblioteca también incluye herramientas para la recarga en segundo plano, invalidación, paginación y desplazamiento infinito, actualizaciones optimistas, herramientas de desarrollo, sincronización del estado del servidor y mutaciones con capacidad de deshacer. Si no se utilizan ninguna de estas funciones, pagas por el tamaño del paquete y su complejidad solo por tres variables. Revisa tú mismo el tamaño actual del paquete; las cifras varían según la versión.

En qué es realmente bueno React Query

Su función principal es la gestión del estado del servidor, manteniendo la interfaz de usuario sincronizada con los datos en el servidor. Destaca en:

  • La recarga en segundo plano mientras el usuario está en la página.
  • La actualización de consultas relacionadas después de una mutación.
  • Paginación y desplazamiento infinito.
  • Actualizaciones optimistas antes de que el servidor confirme los cambios.
  • Deduplicación: dos componentes que solicitan los mismos datos generan una sola solicitud.

Si los necesitas, merece la pena usarlo. Muchas pantallas solo necesitan realizar una solicitud al cargar, mostrar un estado de carga, manejar errores y no volver a solicitar datos innecesariamente.

Por qué los efectos escritos a mano son insuficientes

El enfoque clásico utiliza tres variables de estado y un efecto:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}

Funciona, pero tiene desventajas:

  • Respuestas obsoletas. Si el componente se desmonta o userId cambia durante la solicitud, la respuesta antigua sigue llamando a setState. React 18 eliminó la advertencia de actualización en componentes desmontados, pero una respuesta lenta para un ID anterior puede seguir sobrescribiendo datos más recientes.
  • Falta de deduplicación. Dos componentes, dos solicitudes.
  • Falta de caché. Al volver a la pantalla, se realiza nuevamente la solicitud.
  • Repetición en cada componente.

Creación del hook useFetch

El gancho mantiene un Map a nivel de módulo indexado por URL. El estado inicial se lee de él de forma síncrona, por lo que los datos en caché se renderizan sin un efecto de carga visible. En caso de éxito en el acceso a la caché, se obtiene la respuesta rápidamente; de lo contrario, se envía una señal AbortController con la solicitud, los estados no 2xx se consideran errores y los resultados se almacenan en caché. La función de limpieza cancela la solicitud cuando cambia la URL o el componente se desmonta, se ignora AbortError, y una URL inválida omite la consulta.

// useFetch.js
import { useState, useEffect, useRef } from 'react';

const cache = new Map();

export function useFetch(url, options = {}) {
  const [state, setState] = useState({
    data: cache.get(url) ?? null,
    loading: !cache.has(url),
    error: null
  });

  const abortRef = useRef(null);

  useEffect(() => {
    if (!url) return;
    if (cache.has(url)) {
      setState({ data: cache.get(url), loading: false, error: null });
      return;
    }

    abortRef.current = new AbortController();

    setState(prev => ({ ...prev, loading: true, error: null }));

    fetch(url, { signal: abortRef.current.signal, ...options })
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(data => {
        cache.set(url, data);
        setState({ data, loading: false, error: null });
      })
      .catch(err => {
        if (err.name === 'AbortError') return;
        setState({ data: null, loading: false, error: err.message });
      });

    return () => abortRef.current?.abort();
  }, [url]);

  return state;
}

Aunque se indica como “25 líneas”, en realidad son más cercanas a cuarenta, pero sigue siendo un tamaño reducido. Proporciona estado de carga, manejo de errores, caché, cancelación de solicitudes obsoletas (véase condiciones de carrera que el retraso en las solicitudes no puede resolver), limpieza al desmontar y renderizaciones instantáneas desde la caché.

Dos matices importantes: options falta en las dependencias del efecto, por lo que cambiar únicamente los encabezados no volverá a cargar los datos, y la clave de caché basada únicamente en la URL permite que las solicitudes con cuerpos o encabezados de autenticación diferentes entren en conflicto.

Uso del gancho

El acceso a los datos se reduce a una sola línea. Su estructura es similar a la de React Query, pero la bandera utilizada es loading, no isLoading.

function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}

Dos componentes, una URL

Aquí el encabezado y la barra lateral leen los mismos datos del usuario.

function Header({ userId }) {
  const { data: user } = useFetch(`/api/users/${userId}`);
  return <div>Hello, {user?.name}</div>;
}

function Sidebar({ userId }) {
  const { data: user } = useFetch(`/api/users/${userId}`);
  return <div>{user?.email}</div>;
}

Es aquí donde el gancho hace promesas excesivas. Si ambos se montan al mismo tiempo, ninguno encuentra una entrada en la caché, por lo que se realizan dos solicitudes; solo los montajes posteriores se benefician. La verdadera deduplicación implica almacenar en caché la promesa en proceso, no solo los datos ya resueltos.

Recuperación condicional y parametrizada

Pasar null desactiva la solicitud, al igual que la opción enabled de React Query.

function UserPosts({ userId, enabled }) {
  const { data, loading } = useFetch(enabled ? `/api/users/${userId}/posts` : null);
  // Pass null to skip fetching
}

Dado que la URL es la dependencia del efecto, una URL generada a partir de las propiedades se vuelve a cargar cuando cambian category o page. Codifique la entrada del usuario con encodeURIComponent.

function ProductList({ category, page }) {
  const url = `/api/products?category=${category}&page=${page}`;
  const { data, loading, error } = useFetch(url);
  // Refetches automatically when category or page changes
}

Un panel de control con tres fuentes

Tres llamadas se ejecutan en paralelo, cada sección con su propio esqueleto, sin proveedor ni configuración, y vuelve a renderizar desde la caché.

function Dashboard({ userId }) {
  const { data: user, loading: userLoading } = useFetch(`/api/users/${userId}`);
  const { data: stats, loading: statsLoading } = useFetch(`/api/stats/${userId}`);
  const { data: activity, loading: actLoading } = useFetch(`/api/activity/${userId}`);

  return (
    <div>
      <Header>
        {userLoading ? <Skeleton /> : <UserInfo user={user} />}
      </Header>

      <StatsPanel>
        {statsLoading ? <Skeleton /> : <Stats data={stats} />}
      </StatsPanel>

      <ActivityFeed>
        {actLoading ? <Skeleton /> : <Activity items={activity} />}
      </ActivityFeed>
    </div>
  );
}

Entradas que caducan con TTL

Tener una caché que nunca caduca es inadecuado para datos que cambian. Al almacenar una marca de tiempo por entrada y eliminar aquellas anteriores a cinco minutos se soluciona este problema:

const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

function setCache(url, data) {
  cache.set(url, { data, timestamp: Date.now() });
}

function getCache(url) {
  const entry = cache.get(url);
  if (!entry) return null;
  if (Date.now() - entry.timestamp > CACHE_TTL) {
    cache.delete(url);
    return null;
  }
  return entry.data;
}

Luego, el hook lee y escribe a través de estos auxiliares; solo se muestran las partes modificadas:

const cached = getCache(url);

if (cached) {
  setState({ data: cached, loading: false, error: null });
  return;
}

// ... fetch logic ...
.then(data => {
  setCache(url, data);
  setState({ data, loading: false, error: null });
})

Para los ciclos de vida por llamada, extraer ttl de las opciones y pasar el resto a fetch: valor corto para precios, largo para perfiles. El ejemplo declara data dos veces en un mismo ámbito solo para mostrar dos usos.

export function useFetch(url, options = {}) {
  const { ttl = 5 * 60 * 1000, ...fetchOptions } = options;
  // Use ttl in cache logic
}

// Usage
const { data } = useFetch('/api/live-prices', { ttl: 30 * 1000 }); // 30 second cache
const { data } = useFetch('/api/user-profile', { ttl: 60 * 60 * 1000 }); // 1 hour cache

Las entradas vencidas se eliminan solo al leerse, por lo que el Map no tiene límite en una aplicación de ejecución prolongada que accede a muchas URLs.

Un gancho complementario useMutation

Las escrituras son iniciadas por quien llama la función en lugar de al montarla. Este gancho hace seguimiento del estado de carga, errores y datos, envía un cuerpo en formato JSON, considera como error cualquier respuesta que no sea 2xx, y almacena así como vuelve a lanzar los errores.

// useMutation.js
import { useState } from 'react';

export function useMutation(url, method = 'POST') {
  const [state, setState] = useState({
    loading: false,
    error: null,
    data: null
  });

  const mutate = async (body) => {
    setState({ loading: true, error: null, data: null });

    try {
      const response = await fetch(url, {
        method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body)
      });

      if (!response.ok) throw new Error(`HTTP ${response.status}`);

      const data = await response.json();
      setState({ loading: false, error: null, data });
      return data;
    } catch (err) {
      setState({ loading: false, error: err.message, data: null });
      throw err;
    }
  };

  return { ...state, mutate };
}

Dentro del formulario, el botón queda deshabilitado durante la solicitud y los errores se muestran a continuación. Tenga en cuenta que onSubmit recibe un evento, no los datos del formulario, por lo que el código real debe llamar a event.preventDefault() y construir el cuerpo, por ejemplo, a partir de new FormData(event.currentTarget).

function CreatePost() {
  const { mutate, loading, error } = useMutation('/api/posts');

  const handleSubmit = async (formData) => {
    try {
      const post = await mutate(formData);
      console.log('Created:', post);
    } catch (err) {
      // error state handles this
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button disabled={loading}>
        {loading ? 'Creating...' : 'Create Post'}
      </button>
      {error && <p>{error}</p>}
    </form>
  );
}

Una mutación exitosa no afecta la caché de useFetch, por lo que las listas relacionadas permanecen obsoletas hasta que expire su TTL o usted elimine manualmente las claves.

Qué se sacrifica

Recarga en segundo plano

React Query realiza una recarga al enfocarse la ventana. Puede aproximarse eliminando la entrada de caché al enfocarla, pero este método por sí solo no vuelve a ejecutar el efecto; también se necesita un desencadenante como un contador de estado.

useEffect(() => {
  const handleFocus = () => {
    cache.delete(url); // Invalidate cache
    // Trigger refetch
  };
  window.addEventListener('focus', handleFocus);
  return () => window.removeEventListener('focus', handleFocus);
}, [url]);

Actualizaciones optimistas, herramientas de desarrollo, paginación e invalidación

  • Actualizaciones optimistas con un rollback correcto son difíciles de implementar, y la biblioteca gana claramente en este aspecto.
  • Herramientas de desarrollo que muestran el caché, las consultas y los tiempos de ejecución; la versión hecha en casa solo cuenta con console.log.
  • useInfiniteQuery está bien diseñado; la paginación personalizada requiere notablemente más trabajo.
  • Invalidación global, como volver a cargar cada consulta de “posts”, se convierte en una limpieza manual del caché.

Elegir entre las dos

Preferir React Query para la invalidación basada en mutaciones, el recargue en segundo plano, el desplazamiento infinito o la paginación compleja, las herramientas de desarrollo, o cuando su equipo ya lo conoce. Un pequeño hook es adecuado cuando las pantallas se limitan a obtener y mostrar datos, el caché permanente o con tiempo de vida suficiente es suficiente, no importan las dependencias y la aplicación es pequeña a mediana. Si decide seguir utilizando esta biblioteca, consulte cómo estructurar una capa de datos de TanStack Query.

Migración gradual

Mueva un componente a la vez: cambie la importación y la llamada al hook, además de renombrar isLoading a loading.

// Before
import { useQuery } from '@tanstack/react-query';

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});

// After
import { useFetch } from './useFetch';

const { data, loading, error } = useFetch(`/api/users/${userId}`);

Cuando ya no haya nada que importe la biblioteca, desinstálela.

npm uninstall @tanstack/react-query

Puntos clave

  • La mayoría de las operaciones de obtención de datos son simples, y un Map con marcas de tiempo constituye un caché adecuado para necesidades sencillas.
  • AbortController es la solución nativa para las respuestas obsoletas o fuera de orden.
  • Un gancho mínimo almacena resultados, no solicitudes en curso, por lo que los montajes simultáneos siguen generando llamadas duplicadas.
  • Cada opción de biblioteca representa un punto de riesgo; elija una herramienta según su función, no por costumbre.
  • Pregúntese si el proyecto necesita algo más allá de la obtención básica de datos, y elija en consecuencia.
  • Lecturas relacionadas