Custom Hooks en React: Extrae Lógica Reutilizable

1. Descripción general

Cuando múltiples componentes en tu aplicación React necesitan la misma lógica — llamar a una API, leer localStorage, debounce de entrada — es fácil caer en la trampa de copiar y pegar state y useEffect una y otra vez. Este artículo te muestra cómo encapsular esa lógica en un custom hook: una función que comienza con use, aprovechando los hooks disponibles (useState, useEffect, useRef…) para reutilizarla. Cuando termines, podrás escribir useFetch, useLocalStorage, useDebounce y sabrás cómo testearlos usando renderHook.

2. El problema: lógica repetida entre componentes

Supongamos que tienes dos componentes que necesitan llamar a una API y gestionar loading/error:

function CustomerList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/customers')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

function OrderList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/orders')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

CustomerList y OrderList contienen prácticamente la misma lógica, solo cambia la URL. Si mañana quieres añadir reintentos o cancelación de requests, tendrías que modificarlo en todas partes — esta es una clara señal de que necesitas un custom hook.

3. ¿Qué es un custom hook?

Un custom hook es una función JavaScript ordinaria, cuyo nombre comienza con use, que llama a otros hooks de React en su interior. No retorna JSX — retorna state y funciones, como un pequeño “mini store” vinculado al componente que lo usa.

Nota: Cada vez que un componente llama a un custom hook, React crea una instancia de state independiente. Dos componentes que usan useFetch no comparten state entre sí — solo comparten la lógica.

4. Escribe tu primer custom hook: useFetch

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

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        if (!cancelled) setData(json);
      })
      .catch((err) => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

Explicación:

  • cancelled se usa para evitar una race condition: si url cambia antes de que la request antigua se complete, el resultado antiguo no se establecerá en el state.
  • La función cleanup (return () => { cancelled = true }) se ejecuta cuando el componente se desmonta o url cambia — que es exactamente el ciclo de vida de useEffect.
  • El hook retorna un objeto { data, loading, error } para que quien lo llama pueda desestructurarlo fácilmente.

Ahora el componente es mucho más conciso:

function CustomerList() {
  const { data, loading, error } = useFetch('/api/customers');

  if (loading) return <p>Cargando...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

function OrderList() {
  const { data, loading, error } = useFetch('/api/orders');
  // lógica de renderizado similar, sin duplicación de fetch
}

5. Segundo ejemplo: useLocalStorage

La lógica de sincronizar state con localStorage también es muy propensa a ser copiada y pegada:

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

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

Explicación:

  • useState(() => ...) usa un lazy initializer — la función solo se ejecuta una vez al montarse, no releerá localStorage en cada renderizado.
  • El hook retorna una tupla [value, setValue] igual que useState, lo que hace que sea familiar de inmediato para los usuarios.

Úsalo en un componente:

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Tema actual: {theme}
    </button>
  );
}

5.1. Cuando el state depende del valor anterior

Si necesitas actualizar basándote en el valor anterior (por ejemplo, un contador), usa functional update para evitar stale closure:

setValue((prev) => prev + 1); // más seguro que setValue(value + 1)

6. Tercer ejemplo: useDebounce

Hacer debounce del input de búsqueda es un problema muy común:

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

export function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

Explicación:

  • Cada vez que value cambia, se crea un nuevo setTimeout; el setTimeout anterior se cancela con clearTimeout en el cleanup.
  • Solo cuando el usuario deja de escribir durante delayMs es que debounced se actualiza.
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

7. Convenciones de nombres y Rules of Hooks

Los custom hooks deben cumplir con las Rules of Hooks como los hooks incorporados:

  1. El nombre debe comenzar con use — el plugin de ESLint eslint-plugin-react-hooks se basa en este prefijo para verificar la regla; sin use, no lo reconocerá como hook.
  2. Solo llama a hooks en el top-level de la función, no dentro de if, for o callbacks.
  3. Solo llama a hooks dentro de un function component u otro custom hook, no en funciones ordinarias.
npm install eslint-plugin-react-hooks --save-dev
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

Activar exhaustive-deps te ayuda a no olvidar dependencias en el useEffect de tu custom hook — la causa más común de bugs de “stale data”.

8. Testea tu custom hook

Como un custom hook no retorna JSX, no puedes render lo como un componente. La librería @testing-library/react proporciona renderHook para llamar a hooks en un entorno de prueba.

<!-- No aplica para proyectos React; instala la dependencia vía npm, ver abajo -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('solo actualiza el valor después de que finaliza el delay', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'a', delay: 300 } }
    );

    expect(result.current).toBe('a');

    rerender({ value: 'ab', delay: 300 });
    expect(result.current).toBe('a'); // aún no cambió

    act(() => {
      vi.advanceTimersByTime(300);
    });

    expect(result.current).toBe('ab'); // debounce completado
    vi.useRealTimers();
  });
});

Explicación:

  • renderHook toma una función que llama al hook y retorna result.current — el valor que el hook retorna en el render actual.
  • rerender simula un re-render del componente con nuevas props (como cuando value cambia mientras el usuario escribe).
  • vi.useFakeTimers() + act(() => vi.advanceTimersByTime(...)) te permite controlar el tiempo sin esperar realmente, haciendo que los tests se ejecuten rápido y de forma estable.
✓ useDebounce > solo actualiza el valor después de que finaliza el delay

9. Errores comunes y cuándo NO usar custom hooks

Errores comunes:

  • Olvidar cleanup en useEffect: con useFetch, si no tienes la bandera cancelled, una request antigua que retorna después podría sobrescribir los datos nuevos — causando que se muestren datos incorrectos.
  • Dependencias faltantes: si un useEffect dentro del hook usa una variable externa pero no la declara en el array de dependencias, el hook usará el valor “antiguo” (stale closure).
  • Retornar un objeto nuevo innecesariamente en cada render: si el hook retorna { data, loading, error } como un object literal cada vez, un componente con React.memo debajo podría hacer re-renders innecesarios porque la referencia cambia. Puedes usar useMemo si mides que el problema es real.

Cuándo NO escribir un custom hook:

  • La lógica se usa en un solo lugar y no tiene potencial de reutilización — extraerla solo hace que el código sea más difícil de seguir (tienes que saltar a otro archivo para entender).
  • La lógica es completamente un cálculo puro (pure function), no tiene nada que ver con state/effect — debería ser una función JavaScript ordinaria, no un hook.
  • Cuando necesitas compartir state (no solo lógica) entre múltiples componentes en diferentes ubicaciones del árbol — los custom hooks no pueden hacer esto, necesitas Context o una librería de state management (Zustand, Redux…).

Nota: Los custom hooks resuelven el problema de duplicación de lógica, no el problema de compartir state entre componentes. No confundas estos dos problemas.

10. Conclusión

Los custom hooks son una herramienta para extraer lógica stateful de los componentes, haciendo que el código sea más limpio, más fácil de testear y más reutilizable. Solo necesitas recordar: nombra con el prefijo use, cumple las Rules of Hooks, y testea usando renderHook en lugar de render. Con los useFetch, useLocalStorage, useDebounce anteriores, tienes un marco para escribir otros hooks como useMediaQuery, useForm siguiendo el espíritu de “escribe una vez, usa en muchos lugares”.

reactcustom-hookjavascriptfrontendtesting