React Hooks es uno de esos cambios que, cuando los entiendes de verdad, no quieres volver al sistema anterior. Antes de React 16.8, si necesitabas estado o ciclo de vida en un componente, la única opción era usar una clase. Clases con this, con bind, con constructores… lo que para muchos era una fuente constante de bugs y confusión. Los Hooks llegaron en 2019 y lo simplificaron todo. En este artículo te explico qué son, cómo funcionan y cómo dominarlos desde cero.

Aviso antes de empezar: esto no es un resumen rápido. Vamos a ver cada Hook con ejemplos reales, los errores más comunes y cuándo usar cada uno. Si quieres el overview rápido, la documentación oficial de React está bien. Si quieres entenderlos de verdad, sigue leyendo.

En este artículo:

¿Qué son los Hooks y por qué existen?

Los Hooks son funciones que te permiten «engancharte» a características de React desde componentes funcionales. El nombre viene del inglés hook — anzuelo, gancho. Te enganchas al estado, al ciclo de vida, al contexto.

El problema que resuelven es real. Con los componentes de clase, la lógica relacionada se repartía entre tres métodos distintos — componentDidMount, componentDidUpdate y componentWillUnmount. Si necesitabas suscribirte a un evento y limpiar la suscripción al desmontar, la lógica estaba en dos sitios diferentes del código. Difícil de leer, difícil de mantener, difícil de reutilizar.

Con Hooks, esa misma lógica vive en un solo lugar. Y además se puede extraer a una función independiente — un Hook personalizado — que puedes reutilizar en cualquier componente. Eso es imposible con las clases sin recurrir a patrones complicados como los Higher-Order Components o render props.

Otro punto importante: los Hooks no sustituyen las clases. Si tienes una aplicación con componentes de clase, no hay ninguna prisa por migrar. Los Hooks coexisten sin problema. Pero para código nuevo, la comunidad y el propio equipo de React llevan años recomendando los componentes funcionales con Hooks.

Las reglas que no puedes saltarte

Antes de ver cada Hook, hay dos reglas fundamentales. Si las rompes, el comportamiento de React será impredecible y te costará horas encontrar el bug.

Regla 1: Solo llámalos en el nivel superior. Nunca dentro de condicionales, bucles o funciones anidadas. React necesita que los Hooks se ejecuten siempre en el mismo orden entre renders. Si pones un Hook dentro de un if, puede ejecutarse unas veces y otras no — y React pierde la pista de cuál es cuál.

// MAL — no llames Hooks dentro de condicionales
function Componente({ mostrar }) {
  if (mostrar) {
    const [valor, setValor] = useState(0); // Error: rompe las reglas
  }
}

// BIEN — siempre al nivel superior
function Componente({ mostrar }) {
  const [valor, setValor] = useState(0);

  if (!mostrar) return null;
  return <div>{valor}</div>;
}

Regla 2: Solo llámalos desde componentes React o Hooks personalizados. No en funciones de utilidad normales, no en clases, no fuera del árbol de componentes. El plugin de ESLint eslint-plugin-react-hooks detecta automáticamente estas violaciones — instálalo en cualquier proyecto React.

useState — el estado local de tu componente

Es el Hook que más vas a usar. Te permite añadir una variable de estado a un componente funcional. Recibe el valor inicial y devuelve un array con dos elementos: el valor actual y la función para actualizarlo.

import { useState } from 'react';

function Contador() {
  const [cuenta, setCuenta] = useState(0);

  return (
    <div>
      <p>Valor actual: {cuenta}</p>
      <button onClick={() => setCuenta(cuenta + 1)}>+1</button>
      <button onClick={() => setCuenta(cuenta - 1)}>-1</button>
      <button onClick={() => setCuenta(0)}>Reset</button>
    </div>
  );
}

Algunas cosas que tienes que saber sobre useState:

La actualización es asíncrona. Cuando llamas a setCuenta(cuenta + 1), el estado no cambia inmediatamente. React agrupa múltiples actualizaciones de estado y renderiza solo una vez. Si necesitas basar el nuevo valor en el anterior, usa la versión con función:

// Si dependes del valor anterior, usa la versión funcional
setCuenta(prev => prev + 1);

// Esto es importante cuando tienes actualizaciones rápidas o asíncronas
// Por ejemplo, en un click handler que se ejecuta dos veces rápido:
const incrementarDosVeces = () => {
  setCuenta(prev => prev + 1); // correcto
  setCuenta(prev => prev + 1); // correcto — parte del valor actualizado
  
  // setCuenta(cuenta + 1); // incorrecto — ambas leen el mismo valor antiguo
  // setCuenta(cuenta + 1); // y el resultado final solo suma 1, no 2
};

Con objetos y arrays, reemplaza — no mutes. React compara el valor anterior con el nuevo para decidir si renderizar. Si mutas el objeto directamente sin crear uno nuevo, React no detecta el cambio.

const [usuario, setUsuario] = useState({ nombre: 'Ada', edad: 28 });

// MAL — mutar directamente no provoca re-render
usuario.edad = 29;
setUsuario(usuario); // misma referencia, React no ve el cambio

// BIEN — crear un objeto nuevo con spread
setUsuario(prev => ({ ...prev, edad: 29 }));

// Con arrays, lo mismo:
const [items, setItems] = useState([1, 2, 3]);

// MAL
items.push(4);
setItems(items);

// BIEN
setItems(prev => [...prev, 4]);
setItems(prev => prev.filter(i => i !== 2)); // eliminar
setItems(prev => prev.map(i => i === 2 ? 99 : i)); // actualizar

useEffect — efectos secundarios y ciclo de vida

useEffect es el Hook para manejar efectos secundarios: peticiones a APIs, suscripciones a eventos, manipulación del DOM, timers. Se ejecuta después de cada render — o solo cuando cambien las dependencias que especifiques.

import { useState, useEffect } from 'react';

function PerfilUsuario({ userId }) {
  const [usuario, setUsuario] = useState(null);
  const [cargando, setCargando] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Se ejecuta cuando userId cambia
    setCargando(true);
    setError(null);

    fetch(`https://api.ejemplo.com/usuarios/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Error al cargar el usuario');
        return res.json();
      })
      .then(data => {
        setUsuario(data);
        setCargando(false);
      })
      .catch(err => {
        setError(err.message);
        setCargando(false);
      });
  }, [userId]); // <-- array de dependencias

  if (cargando) return <p>Cargando...</p>;
  if (error) return <p>Error: {error}</p>;
  return <h1>Hola, {usuario.nombre}</h1>;
}

El array de dependencias controla cuándo se ejecuta el efecto:

La función de limpieza. Si tu efecto crea una suscripción, un timer o un event listener, debes limpiarlos cuando el componente se desmonte. Para eso, devuelves una función desde useEffect:

useEffect(() => {
  const handler = (e) => console.log(e.key);
  document.addEventListener('keydown', handler);

  // Esta función se ejecuta al desmontar el componente
  // o antes de ejecutar el efecto de nuevo (si las dependencias cambian)
  return () => {
    document.removeEventListener('keydown', handler);
  };
}, []);

// Otro ejemplo — cancelar peticiones con AbortController
useEffect(() => {
  const controller = new AbortController();

  fetch('/api/datos', { signal: controller.signal })
    .then(res => res.json())
    .then(setDatos)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err.message);
    });

  return () => controller.abort(); // cancela la petición si el componente se desmonta
}, []);

El error más común con useEffect es el array de dependencias incompleto. Si usas una variable dentro del efecto, tiene que estar en el array. De lo contrario, el efecto puede leer valores obsoletos. El plugin de ESLint react-hooks/exhaustive-deps te avisará cuando esto pase.

useContext — estado global sin instalar Redux

useContext te permite leer un contexto de React desde cualquier componente del árbol, sin necesidad de pasar props manualmente por cada nivel. Es la solución nativa de React para estado global ligero: usuario autenticado, tema de la aplicación, idioma, preferencias.

import { createContext, useContext, useState } from 'react';

// 1. Crear el contexto con un valor por defecto
const TemaContext = createContext('claro');

// 2. Proveedor — envuelve la parte del árbol que necesita el contexto
function AppProvider({ children }) {
  const [tema, setTema] = useState('claro');

  return (
    <TemaContext.Provider value={{ tema, setTema }}>
      {children}
    </TemaContext.Provider>
  );
}

// 3. Consumidor — cualquier componente dentro del Provider puede usar el contexto
function BotonTema() {
  const { tema, setTema } = useContext(TemaContext);

  return (
    <button onClick={() => setTema(tema === 'claro' ? 'oscuro' : 'claro')}>
      Tema actual: {tema}
    </button>
  );
}

// Hook personalizado para encapsular el useContext (buena práctica)
function useTema() {
  const context = useContext(TemaContext);
  if (!context) {
    throw new Error('useTema debe usarse dentro de AppProvider');
  }
  return context;
}

Una cosa importante: cuando el valor del contexto cambia, todos los componentes que lo consumen se re-renderizan. Si el contexto tiene muchos valores que cambian frecuentemente, considera dividirlo en varios contextos más pequeños o usar una solución más especializada como Zustand o Redux Toolkit.

useRef — referencias y valores que persisten sin provocar re-renders

useRef devuelve un objeto mutable con una propiedad current. Lo especial es que cambiar current no provoca un re-render del componente. Tiene dos usos principales.

Acceder a elementos del DOM directamente:

import { useRef, useEffect } from 'react';

function FormularioBusqueda() {
  const inputRef = useRef(null);

  useEffect(() => {
    // Enfocar el input al montar el componente
    inputRef.current.focus();
  }, []);

  const limpiarYEnfocar = () => {
    inputRef.current.value = '';
    inputRef.current.focus();
  };

  return (
    <div>
      <input ref={inputRef} type="text" placeholder="Buscar..." />
      <button onClick={limpiarYEnfocar}>Limpiar</button>
    </div>
  );
}

Guardar valores que persisten entre renders sin provocar re-render:

import { useState, useEffect, useRef } from 'react';

function Cronometro() {
  const [tiempo, setTiempo] = useState(0);
  const [activo, setActivo] = useState(false);
  const intervalRef = useRef(null); // guardamos el ID del intervalo

  useEffect(() => {
    if (activo) {
      intervalRef.current = setInterval(() => {
        setTiempo(prev => prev + 1);
      }, 1000);
    } else {
      clearInterval(intervalRef.current);
    }

    return () => clearInterval(intervalRef.current);
  }, [activo]);

  return (
    <div>
      <p>{tiempo} segundos</p>
      <button onClick={() => setActivo(!activo)}>
        {activo ? 'Pausar' : 'Iniciar'}
      </button>
      <button onClick={() => { setActivo(false); setTiempo(0); }}>
        Reset
      </button>
    </div>
  );
}

// Otro uso habitual: guardar el valor anterior de una prop o estado
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current; // devuelve el valor del render anterior
}

useReducer — cuando useState se queda pequeño

Cuando el estado de un componente tiene múltiples sub-valores relacionados o la lógica de actualización es compleja, useReducer es más manejable que useState. Si has trabajado con Redux, el patrón te va a resultar familiar.

import { useReducer } from 'react';

// El reducer define cómo cambia el estado según la acción
function carritoReducer(estado, accion) {
  switch (accion.type) {
    case 'AÑADIR':
      const itemExistente = estado.items.find(i => i.id === accion.producto.id);
      if (itemExistente) {
        return {
          ...estado,
          items: estado.items.map(i =>
            i.id === accion.producto.id
              ? { ...i, cantidad: i.cantidad + 1 }
              : i
          )
        };
      }
      return {
        ...estado,
        items: [...estado.items, { ...accion.producto, cantidad: 1 }]
      };

    case 'ELIMINAR':
      return {
        ...estado,
        items: estado.items.filter(i => i.id !== accion.id)
      };

    case 'VACIAR':
      return { items: [], total: 0 };

    default:
      return estado;
  }
}

function Carrito() {
  const [carrito, dispatch] = useReducer(carritoReducer, { items: [], total: 0 });

  return (
    <div>
      <p>{carrito.items.length} productos en el carrito</p>
      <button onClick={() => dispatch({ type: 'VACIAR' })}>
        Vaciar carrito
      </button>
    </div>
  );
}

La ventaja de useReducer es que toda la lógica de actualización vive en una función pura — el reducer — que es fácil de testear por separado. Además, el dispatch tiene una referencia estable, lo que evita algunos problemas de rendimiento que veremos en el siguiente apartado.

useMemo y useCallback — optimización de rendimiento

Estos dos Hooks son para optimización. El consejo general es no usarlos por defecto — úsalos cuando tengas un problema de rendimiento real, no de forma preventiva. El código sin ellos es más simple y más fácil de leer.

useMemo memoriza el resultado de un cálculo costoso y solo lo recalcula cuando sus dependencias cambian:

import { useState, useMemo } from 'react';

function ListaProductos({ productos, filtro }) {
  // Sin useMemo: este cálculo se repite en CADA render del componente
  // Con useMemo: solo se recalcula cuando 'productos' o 'filtro' cambian
  const productosFiltrados = useMemo(() => {
    console.log('Filtrando productos...'); // verás que solo se ejecuta cuando cambia el filtro
    return productos.filter(p =>
      p.nombre.toLowerCase().includes(filtro.toLowerCase())
    );
  }, [productos, filtro]);

  return (
    <ul>
      {productosFiltrados.map(p => (
        <li key={p.id}>{p.nombre} — {p.precio}€</li>
      ))}
    </ul>
  );
}

useCallback memoriza una función. Es útil cuando pasas callbacks a componentes hijos envueltos en React.memo — evita que el hijo se re-renderice porque la función «cambió» (en JavaScript, cada render crea una nueva referencia de función):

import { useState, useCallback } from 'react';

function Padre() {
  const [contador, setContador] = useState(0);
  const [texto, setTexto] = useState('');

  // Sin useCallback: nueva referencia en cada render → Hijo se re-renderiza siempre
  // Con useCallback: misma referencia si las deps no cambian → Hijo no se re-renderiza
  const handleClick = useCallback(() => {
    setContador(prev => prev + 1);
  }, []); // sin dependencias: la función nunca cambia

  return (
    <div>
      <input value={texto} onChange={e => setTexto(e.target.value)} />
      <Hijo onClick={handleClick} />
      <p>Contador: {contador}</p>
    </div>
  );
}

// React.memo evita re-renders si las props no cambian
const Hijo = React.memo(function Hijo({ onClick }) {
  console.log('Hijo renderizado');
  return <button onClick={onClick}>Incrementar</button>;
});

Hooks personalizados — la razón por la que todo esto merece la pena

Esta es, en mi opinión, la funcionalidad más potente del sistema de Hooks. Un Hook personalizado es simplemente una función de JavaScript cuyo nombre empieza por use y que puede llamar a otros Hooks internamente. Te permite extraer lógica compleja de un componente y reutilizarla en cualquier otro.

Antes de los Hooks, compartir lógica con estado entre componentes requería Higher-Order Components o render props — patrones que funcionan pero que añaden capas al árbol de componentes y dificultan la lectura. Con Hooks personalizados, extraes esa lógica a una función y listo.

// Hook para peticiones a una API
function useFetch(url) {
  const [datos, setDatos] = useState(null);
  const [cargando, setCargando] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setCargando(true);

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
        return res.json();
      })
      .then(data => {
        setDatos(data);
        setCargando(false);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err.message);
          setCargando(false);
        }
      });

    return () => controller.abort();
  }, [url]);

  return { datos, cargando, error };
}

// Úsalo en cualquier componente sin repetir la lógica
function ListaArticulos() {
  const { datos: articulos, cargando, error } = useFetch('/api/articulos');

  if (cargando) return <p>Cargando...</p>;
  if (error) return <p>Error: {error}</p>;
  return <ul>{articulos.map(a => <li key={a.id}>{a.titulo}</li>)}</ul>;
}

function DetalleProducto({ id }) {
  const { datos: producto, cargando } = useFetch(`/api/productos/${id}`);
  // misma lógica, cero código repetido
}

Más ejemplos de Hooks personalizados útiles:

// Hook para localStorage
function useLocalStorage(clave, valorInicial) {
  const [valor, setValor] = useState(() => {
    try {
      const item = window.localStorage.getItem(clave);
      return item ? JSON.parse(item) : valorInicial;
    } catch {
      return valorInicial;
    }
  });

  const guardar = (nuevoValor) => {
    setValor(nuevoValor);
    localStorage.setItem(clave, JSON.stringify(nuevoValor));
  };

  return [valor, guardar];
}

// Hook para detectar el tamaño de la ventana
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    const handleResize = () => setSize({
      width: window.innerWidth,
      height: window.innerHeight
    });
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// Hook para debounce — retrasar la ejecución hasta que el usuario deje de escribir
function useDebounce(valor, delay = 300) {
  const [valorDebounced, setValorDebounced] = useState(valor);

  useEffect(() => {
    const timer = setTimeout(() => setValorDebounced(valor), delay);
    return () => clearTimeout(timer);
  }, [valor, delay]);

  return valorDebounced;
}

// Uso del debounce — buscar mientras el usuario escribe sin hacer una petición por cada tecla
function BuscadorProductos() {
  const [busqueda, setBusqueda] = useState('');
  const busquedaDebounced = useDebounce(busqueda, 400);
  const { datos: resultados, cargando } = useFetch(
    busquedaDebounced ? `/api/productos?q=${busquedaDebounced}` : null
  );

  return (
    <div>
      <input
        value={busqueda}
        onChange={e => setBusqueda(e.target.value)}
        placeholder="Buscar productos..."
      />
      {cargando && <p>Buscando...</p>}
      {resultados && <ul>{resultados.map(p => <li key={p.id}>{p.nombre}</li>)}</ul>}
    </div>
  );
}

Errores comunes que todo el mundo comete al principio

Bucles infinitos con useEffect. Si actualizas el estado dentro de un useEffect que tiene ese mismo estado en las dependencias, tienes un bucle infinito. El efecto se ejecuta, actualiza el estado, el estado cambia, el efecto se ejecuta de nuevo…

// MAL — bucle infinito
useEffect(() => {
  setContador(contador + 1); // actualiza contador
}, [contador]); // que está en las dependencias

// BIEN — usar la versión funcional no requiere contador en las deps
useEffect(() => {
  setContador(prev => prev + 1);
}, []); // solo al montar

Leer estado obsoleto en closures. Las funciones dentro de un useEffect o un event handler capturan los valores del momento en que se crean. Si el estado cambia después, la función sigue viendo el valor antiguo.

// MAL — el intervalo captura contador = 0 y nunca lo actualiza
useEffect(() => {
  setInterval(() => {
    console.log(contador); // siempre imprime 0
  }, 1000);
}, []);

// BIEN — usar ref para acceder al valor más reciente
const contadorRef = useRef(contador);
contadorRef.current = contador;

useEffect(() => {
  setInterval(() => {
    console.log(contadorRef.current); // siempre el valor actual
  }, 1000);
}, []);

Objetos y arrays como dependencias de useEffect. En JavaScript, {} === {} es false. Cada render crea un nuevo objeto aunque tenga los mismos valores. Si pones un objeto como dependencia de useEffect, el efecto se ejecutará en cada render.

// MAL — opciones es un nuevo objeto en cada render → efecto en cada render
function Componente({ id }) {
  const opciones = { metodo: 'GET', cache: true }; // nuevo objeto cada render

  useEffect(() => {
    fetchDatos(id, opciones);
  }, [id, opciones]); // opciones cambia siempre
}

// BIEN — opciones fuera del componente (si son constantes)
const OPCIONES_FETCH = { metodo: 'GET', cache: true };

function Componente({ id }) {
  useEffect(() => {
    fetchDatos(id, OPCIONES_FETCH);
  }, [id]);
}

¿Por qué aprender React Hooks en 2026?

Los Hooks son el estándar de facto en React desde hace años. Toda la documentación oficial, todos los tutoriales nuevos, todos los proyectos serios los usan. Si trabajas con React — o quieres hacerlo — no hay vuelta atrás.

Lo bueno es que la curva de aprendizaje, aunque existe, es corta. Con useState y useEffect puedes construir el 90% de las cosas. useContext, useRef y useReducer cubren prácticamente todo lo demás. Los Hooks de optimización — useMemo y useCallback — los usarás cuando tengas un problema de rendimiento real, no antes.

Mi recomendación: construye algo con ellos. No te quedes en los tutoriales. Haz una pequeña app — un gestor de tareas, un buscador de películas, lo que sea — y usa Hooks reales. Los errores que cometas en ese proyecto los recordarás mucho mejor que cualquier cosa que puedas leer.