Cómo consumir APIs en React sin ensuciar tus componentes
Aprende cómo transformar datos entre React y tu backend usando adapters y mappers para crear componentes más fáciles de mantener y escalar.
Gabriel Jiménez | Hace 8 días
Muchos desarrolladores empiezan creando hooks personalizados apenas detectan código repetido en dos o más archivos. El problema es que no toda la lógica necesita convertirse en un hook.
A veces ayudan a simplificar componentes. Otras veces solo agregan capas de abstracción innecesarias y hacen más difícil entender el flujo de la aplicación.
En este artículo veremos cuándo realmente vale la pena crear un hook personalizado en React, cómo identificar que es un buen momento para crear uno y qué señales indican que probablemente no lo necesitamos.
Los hooks personalizados son funciones que encapsulan lógica reutilizable entre diferentes componentes de React. Generalmente, la convención que React recomienda para identificarlos dentro de una aplicación es utilizar el prefijo use, por ejemplo: useUsers, useAuth o useForm.
Esto permite:
Una de las principales señales de que probablemente necesitamos un hook personalizado es cuando notamos que fragmentos de código idénticos —o con una funcionalidad muy similar— empiezan a repetirse en varios componentes.
Por ejemplo, imaginemos que diferentes pantallas necesitan consultar usuarios, pero cada una aplica filtros distintos mediante query params.
function ActiveUsers() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("/api/users?status=active")
.then((response) => response.json())
.then((data) => {
setUsers(data);
});
}, []);
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
function AdminUsers() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("/api/users?role=admin")
.then((response) => response.json())
.then((data) => {
setUsers(data);
});
}, []);
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Aunque las consultas cambian, la lógica sigue siendo prácticamente la misma. La única diferencia son los parámetros enviados al backend.
En este tipo de escenarios, podemos crear un hook personalizado que reciba los queryParams dinámicamente:
function useUsers(queryParams = "") {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch(`/api/users?${queryParams}`)
.then((response) => response.json())
.then((data) => {
setUsers(data);
});
}, [queryParams]);
return users;
}
De esta forma, cada componente puede reutilizar la misma lógica y únicamente modificar los filtros que necesita:
function ActiveUsers() {
const users = useUsers("status=active");
. . .
}
function AdminUsers() {
const users = useUsers("role=admin");
. . .
}
Cuando notamos que nuestros componentes empiezan a crecer demasiado —muchos states, useEffects y lógica de negocio mezclada— probablemente es un buen momento para revisar si parte de esa lógica puede extraerse a un hook personalizado.
Los componentes con muchas responsabilidades suelen ser una señal fuerte de que parte de su lógica podría extraerse a un hook personalizado.
Por ejemplo, imaginemos que tenemos dos componentes distintos donde ambos necesitan:
function ProductCard({ productId }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [isAvailable, setIsAvailable] = useState(false);
useEffect(() => {
fetch(`/api/products/${productId}`)
.then((response) => response.json())
.then((data) => {
setProduct(data);
setIsAvailable(data.stock > 0);
setLoading(false);
});
}, [productId]);
if (loading) {
return <p>Cargando producto...</p>;
}
return (
<div>
<h2>{product.name}</h2>
{isAvailable ? (
<button>Comprar</button>
) : (
<p>Sin existencias</p>
)}
</div>
);
}
function ProductDetails({ productId }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [isAvailable, setIsAvailable] = useState(false);
useEffect(() => {
fetch(`/api/products/${productId}`)
.then((response) => response.json())
.then((data) => {
setProduct(data);
setIsAvailable(data.stock > 0);
setLoading(false);
});
}, [productId]);
if (loading) {
return <p>Cargando información...</p>;
}
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
{isAvailable ? (
<span>Disponible</span>
) : (
<span>Agotado</span>
)}
</section>
);
}
Aunque ambos componentes renderizan cosas distintas, comparten prácticamente la misma lógica de negocio.
En este tipo de casos, mover el comportamiento compartido a un hook personalizado puede ayudar a reducir responsabilidades dentro del componente y mantener una mejor separación entre lógica y UI.
function useProduct(productId) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [isAvailable, setIsAvailable] = useState(false);
useEffect(() => {
setLoading(true);
fetch(`/api/products/${productId}`)
.then((response) => response.json())
.then((data) => {
setProduct(data);
setIsAvailable(data.stock > 0);
setLoading(false);
});
}, [productId]);
return {
product,
loading,
isAvailable,
};
}
Y ahora los componentes quedan más enfocados en la UI:
function ProductCard({ productId }) {
const { product, loading, isAvailable } = useProduct(productId);
if (loading) {
return <p>Cargando producto...</p>;
}
return (
<div>
<h2>{product.name}</h2>
{isAvailable ? (
<button>Comprar</button>
) : (
<p>Sin existencias</p>
)}
</div>
);
}
Otra señal importante aparece cuando nuestras pruebas empiezan a compartir comportamientos muy parecidos. Cuando esto sucede, puede ser buen momento para revisar el código productivo y detectar si existe lógica que podría vivir dentro de un hook personalizado.
Por ejemplo, imaginemos que tenemos dos componentes distintos que consultan productos y muestran un mensaje cuando el producto no tiene existencias.
test("muestra mensaje de producto agotado en ProductCard", async () => {
fetch.mockResolvedValueOnce({
json: async () => ({
id: 1,
name: "Playera",
stock: 0,
}),
});
render(<ProductCard productId={1} />);
expect(await screen.findByText("Sin existencias")).toBeInTheDocument();
});
Las pruebas son para componentes diferentes, pero ambas dependen de la misma lógica: consultar el producto y determinar si está disponible según su stock.
Cuando este patrón se repite muchas veces, podemos extraer esa lógica a un hook como useProduct. Así, la regla de negocio se prueba una sola vez desde el hook, y las pruebas de los componentes se enfocan únicamente en validar lo que renderizan.
Para este ejemplo usaremos el clásico caso de autenticación. Revisar si un usuario está autenticado es algo muy común en aplicaciones React, por eso es fácil que esta lógica termine repetida en varios componentes.
Primero veamos cómo podría verse un componente que todavía no usa un hook personalizado:
import { useEffect, useState } from "react";
function AccountPage() {
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
const [userName, setUserName] = useState("");
useEffect(() => {
fetch("/api/me")
.then((response) => response.json())
.then((data) => {
const authenticated = Boolean(data.user);
setUser(data.user);
setIsAuthenticated(authenticated);
setUserName(data.user ? data.user.name.toUpperCase() : "");
setLoading(false);
});
}, []);
if (loading) {
return <p>Validando sesión...</p>;
}
if (!isAuthenticated) {
return <p>Debes iniciar sesión para ver esta página.</p>;
}
return (
<section>
<h1>Mi cuenta</h1>
<p>Bienvenido, {userName}</p>
</section>
);
}
export default AccountPage;
Este componente funciona, pero empieza a mezclar varias responsabilidades:
El problema aparece cuando esta misma lógica comienza a repetirse en otras pantallas: Dashboard, ProfilePage, SettingsPage, CheckoutPage, etc.
En ese punto, el componente ya no solo representa una pantalla; también está cargando con lógica de autenticación que probablemente podría vivir en un hook personalizado como useAuth.
Ahora que detectamos que la lógica de autenticación puede repetirse en múltiples componentes, podemos mover ese comportamiento a un hook personalizado.
La idea es separar la lógica de autenticación del componente.
import { useEffect, useState } from "react";
function useAuth() {
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/me")
.then((response) => response.json())
.then((data) => {
setUser(data.user);
setIsAuthenticated(Boolean(data.user));
setLoading(false);
});
}, []);
return {
user,
isAuthenticated,
loading,
};
}
Ahora el componente queda mucho más enfocado únicamente en renderizar la UI:
function AccountPage() {
const { user, isAuthenticated, loading } = useAuth();
if (loading) {
return <p>Validando sesión...</p>;
}
if (!isAuthenticated) {
return <p>Debes iniciar sesión para ver esta página.</p>;
}
return (
<section>
<h1>Mi cuenta</h1>
<p>Bienvenido, {user.name}</p>
</section>
);
}
La ventaja no es únicamente tener menos líneas de código. Ahora la lógica de autenticación vive en un solo lugar y puede reutilizarse desde cualquier componente de la aplicación.
Además, el componente se vuelve más fácil de leer, mantener y probar.
Los hooks personalizados son una técnica muy poderosa, pero también conllevan una gran responsabilidad. Creer que absolutamente toda lógica debería convertirse en un hook es uno de los errores más comunes cuando comenzamos a trabajar con React.
Como en muchas cosas dentro del desarrollo de software, debe existir equilibrio y criterio. Un hook debería ayudarnos a simplificar el código, no a volverlo más complejo.
A veces, extraer lógica a un hook mejora la reutilización, el testing y la separación de responsabilidades. Pero otras veces únicamente movemos código de lugar y terminamos agregando capas de abstracción innecesarias.
Si un hook solo se va a usar en un lugar —o creemos que “tal vez mañana” se usará en otro— puede ser una señal de sobreingeniería.
En estos casos, recomiendo crear el hook cuando el propio componente lo empieza a pedir: muchas responsabilidades, mucha lógica de negocio o código repetido en varios lugares.
A veces copiar y pegar una pequeña porción de código es una solución aceptable. Más adelante, si esa lógica empieza a repetirse o crecer, entonces sí puede convertirse en un hook personalizado.
Otro error común es crear hooks solo porque tenemos una regla mental como:
“Todo el código que hace peticiones HTTP debe vivir en /hooks/api”.
El problema es que mover código a otra carpeta no siempre mejora el diseño. Si el hook no reduce duplicación, no simplifica el componente o no encapsula una responsabilidad clara, probablemente solo estamos cambiando el problema de lugar.
Si la lógica es demasiado sencilla, probablemente no necesitamos crear un hook personalizado todavía.
Por ejemplo, si solo tenemos un useState y una función pequeña dentro de un componente, extraer eso a un hook puede agregar más complejidad de la que resuelve.
Un hook debería nacer de una necesidad clara, no de la intención de hacer que el código “se vea más avanzado”.
Los niveles de abstracción pueden ser útiles, pero también pueden convertirse en un problema.
En el caso de los hooks, a veces es más difícil seguir el flujo cuando la lógica queda repartida entre varios hooks, componentes y archivos. Si para entender un comportamiento tenemos que saltar entre demasiadas capas, probablemente la abstracción no está ayudando.
Un buen hook debería hacer que el código sea más fácil de entender, no obligarnos a perseguir la lógica por toda la aplicación.
Los hooks personalizados son una técnica muy poderosa dentro del ecosistema de React. Sin embargo, no deberían existir únicamente porque “se ven bien” o porque queremos mover código a otra carpeta.
Generalmente, los mejores hooks nacen cuando la propia aplicación empieza a mostrar señales claras: lógica repetida, componentes con demasiadas responsabilidades, pruebas duplicadas o flujos difíciles de mantener.
Si quieres dejar de crear componentes difíciles de mantener, llenos de lógica repetida y cada vez más complicados de probar, te recomiendo visitar mi hub de Testing en React. Ahí aprenderás cómo el testing no solo sirve para detectar errores, sino también para diseñar componentes más escalables, desacoplados y fáciles de evolucionar conforme crece tu aplicación.
Aprende cómo transformar datos entre React y tu backend usando adapters y mappers para crear componentes más fáciles de mantener y escalar.
Aprende a organizar carpetas y archivos en React para crear proyectos más mantenibles, escalables y fáciles de desarrollar.
Aprende por qué algunos componentes en React son difíciles de probar y cómo organizarlos para crear código más escalable y mantenible.
Aprende a detectar componentes difíciles de mantener en React antes de que se conviertan en deuda técnica y errores costosos.