tutoriales.com

React Hooks Personalizados: Creando Lógica Reutilizable y Abstraída 🛠️

Este tutorial te guiará a través del proceso de creación de React Hooks personalizados. Descubrirás cómo abstraer la lógica compleja de tus componentes, haciendo tu código más limpio, legible y fácil de mantener y reutilizar en tus aplicaciones React.

Intermedio20 min de lectura6 views
Reportar error

Los React Hooks revolucionaron la forma en que gestionamos el estado y los efectos secundarios en componentes funcionales. Pero más allá de los Hooks integrados como useState, useEffect o useContext, existe un poderoso concepto: los Hooks personalizados.

Los Hooks personalizados te permiten extraer lógica de componentes en funciones reutilizables. Si te encuentras duplicando código o si tus componentes funcionales están creciendo demasiado en complejidad, es una señal clara de que necesitas un Hook personalizado. Son la herramienta perfecta para hacer que tu aplicación React sea más modular, escalable y mantenible.

En este tutorial, exploraremos qué son los Hooks personalizados, por qué son tan útiles y, lo más importante, cómo crearlos desde cero con ejemplos prácticos y paso a paso.


¿Qué son los React Hooks Personalizados? 🤔

Un Hook personalizado es una función de JavaScript cuyo nombre comienza con use y que puede llamar a otros Hooks (integrados o personalizados). Son una convención para encapsular lógica de estado y/o efectos secundarios de React que se puede reutilizar entre diferentes componentes sin duplicar código.

Piensa en ellos como recetas de lógica React que puedes "mezclar y combinar" en tus componentes. No son un reemplazo para los componentes o los higher-order components (HOCs), sino una forma de compartir lógica de estado entre ellos.

💡 Consejo: La convención de nombrar los Hooks personalizados con `use` (por ejemplo, `useMiHook`, `useToggle`) es crucial. React utiliza esta convención para aplicar las reglas de los Hooks y para que las herramientas de linting puedan detectar errores.

¿Por qué usar Hooks Personalizados? ✨

Hay varias razones de peso para adoptar los Hooks personalizados en tus proyectos:

  • Reutilización de lógica: Evita la duplicación de código al encapsular lógica común que se usa en múltiples componentes.
  • Abstracción de complejidad: Mantén tus componentes limpios y enfocados en la UI, moviendo la lógica compleja (manejo de estado, suscripciones, llamadas a API) a Hooks separados.
  • Legibilidad y Mantenibilidad: Un código más modular es más fácil de leer, entender y mantener. Cambios en la lógica no afectarán directamente a los componentes que la usan, solo al Hook.
  • Testing simplificado: La lógica encapsulada en un Hook personalizado es más fácil de testear de forma aislada.
  • Organización del código: Ayuda a estructurar tus archivos y carpetas de manera más lógica y funcional.
Sin Hooks Personalizados Con Hooks Personalizados Componente A Lógica de Estado Efectos Secundarios Manejo de Eventos Componente B Lógica de Estado Efectos Secundarios Manejo de Eventos Lógica Reutilizable (useEstado, useEfectos, useEventos) Componente A Solo Lógica de UI Componente B Solo Lógica de UI

Reglas de los Hooks 📜

Antes de sumergirnos en la creación, es fundamental recordar las dos reglas de los Hooks de React. Estas reglas se aplican tanto a los Hooks integrados como a tus propios Hooks personalizados:

  1. Solo llama Hooks en el nivel superior: No llames Hooks dentro de bucles, condiciones o funciones anidadas. Siempre en la parte superior de tu función de componente o Hook personalizado.
  2. Solo llama Hooks desde funciones de React: Llama Hooks desde componentes funcionales de React o desde otros Hooks personalizados. No los llames desde funciones regulares de JavaScript.
⚠️ Advertencia: Ignorar estas reglas puede llevar a comportamientos inesperados, errores de renderizado y dificultades para depurar, ya que React depende de un orden consistente de las llamadas a Hooks.

Creando tu Primer Hook Personalizado: useToggle 💡

Empecemos con un ejemplo sencillo pero muy útil: un Hook para gestionar el estado de "encendido/apagado" o "verdadero/falso". Esto es común para modales, menús desplegables, o cualquier elemento que alterne entre dos estados.

Escenario sin useToggle (Problema) ❌

Imagina que tienes múltiples componentes que necesitan un estado booleano para mostrar/ocultar elementos:

// Componente A
import React, { useState } from 'react';

function MyModal() {
  const [isOpen, setIsOpen] = useState(false);

  const toggle = () => setIsOpen(!isOpen);

  return (
    <div>
      <button onClick={toggle}>Abrir Modal</button>
      {isOpen && (
        <div style={{ border: '1px solid black', padding: '20px' }}>
          <h2>Contenido del Modal</h2>
          <button onClick={toggle}>Cerrar</button>
        </div>
      )}
    </div>
  );
}

// Componente B
function MyDropdown() {
  const [isVisible, setIsVisible] = useState(false);

  const toggleVisibility = () => setIsVisible(!isVisible);

  return (
    <div>
      <button onClick={toggleVisibility}>Mostrar Dropdown</button>
      {isVisible && (
        <ul style={{ border: '1px solid gray', listStyle: 'none', padding: '10px' }}>
          <li>Opción 1</li>
          <li>Opción 2</li>
        </ul>
      )}
    </div>
  );
}

Como puedes ver, la lógica useState y la función toggle se repiten. Aquí es donde useToggle brilla.

Implementando useToggle

Crearemos un archivo useToggle.js (o hooks/useToggle.js en una estructura de carpetas más grande):

// hooks/useToggle.js
import { useState, useCallback } from 'react';

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => {
    setValue(currentValue => !currentValue);
  }, []); // El array de dependencias vacío asegura que la función toggle sea la misma en cada renderizado

  return [value, toggle];
}

export default useToggle;

Explicación:

  • Definimos una función useToggle que acepta un initialValue (por defecto false).
  • Internamente, usa useState para gestionar el valor booleano.
  • La función toggle actualiza el estado. Usamos useCallback para memorizar esta función. Esto evita que toggle se recree en cada renderizado, lo cual es una optimización importante cuando pasas esta función a componentes hijos.
  • Retornamos un array con el value actual y la función toggle, similar a cómo useState retorna su valor y su setter.

Usando useToggle en Componentes 🚀

Ahora, podemos refactorizar nuestros componentes para usar useToggle:

// Componente A refactorizado
import React from 'react';
import useToggle from './hooks/useToggle'; // Asegúrate de la ruta correcta

function MyModalRefactored() {
  const [isOpen, toggleModal] = useToggle(false);

  return (
    <div>
      <button onClick={toggleModal}>Abrir Modal</button>
      {isOpen && (
        <div style={{ border: '1px solid black', padding: '20px' }}>
          <h2>Contenido del Modal</h2>
          <button onClick={toggleModal}>Cerrar</button>
        </div>
      )}
    </div>
  );
}

// Componente B refactorizado
import React from 'react';
import useToggle from './hooks/useToggle'; // Asegúrate de la ruta correcta

function MyDropdownRefactored() {
  const [isVisible, toggleDropdown] = useToggle(false);

  return (
    <div>
      <button onClick={toggleDropdown}>Mostrar Dropdown</button>
      {isVisible && (
        <ul style={{ border: '1px solid gray', listStyle: 'none', padding: '10px' }}>
          <li>Opción 1</li>
          <li>Opción 2</li>
        </ul>
      )}
    </div>
  );
}

¡Mucho más limpio y conciso! La lógica de alternancia ahora reside en un solo lugar y es fácilmente reutilizable.


Creando un Hook más Avanzado: useLocalStorage 💾

Un escenario común es la persistencia de datos en el localStorage del navegador. Podemos crear un Hook personalizado para abstraer esta lógica.

Requisitos para useLocalStorage

Nuestro Hook debería:

  • Permitir almacenar cualquier valor serializable (strings, números, objetos JSON).
  • Tomar una clave (key) y un valor inicial (initialValue).
  • Retornar el valor actual y una función para actualizarlo.
  • Manejar la inicialización desde localStorage al montar el componente.
  • Actualizar localStorage cada vez que el valor cambie.
  • Manejar errores de serialización/deserialización si los hubiera.

Implementando useLocalStorage ⚙️

Crearemos un archivo useLocalStorage.js:

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

function useLocalStorage(key, initialValue) {
  // Función para obtener el valor inicial del localStorage
  // Se envuelve en una función para que solo se ejecute una vez al renderizar
  const getStoredValue = useCallback(() => {
    if (typeof window === 'undefined') {
      return initialValue; // Para entornos de servidor (SSR)
    }
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key “${key}”:`, error);
      return initialValue;
    }
  }, [key, initialValue]);

  const [value, setValue] = useState(getStoredValue);

  // useEffect para actualizar localStorage cuando 'value' cambia
  useEffect(() => {
    if (typeof window !== 'undefined') {
      try {
        window.localStorage.setItem(key, JSON.stringify(value));
      } catch (error) {
        console.error(`Error setting localStorage key “${key}”:`, error);
      }
    }
  }, [key, value]);

  return [value, setValue];
}

export default useLocalStorage;

Explicación Detallada:

  1. getStoredValue: Esta función se encarga de intentar leer el valor del localStorage cuando el Hook se inicializa. Está envuelta en useCallback y se usa como la función de inicialización de useState para asegurar que el localStorage solo se lea una vez. Incluye manejo de errores y compatibilidad con SSR.
  2. useState(getStoredValue): El estado value se inicializa llamando a getStoredValue. React solo llama a esta función una vez, al primer renderizado.
  3. useEffect: Este Hook es crucial. Se ejecuta cada vez que key o value cambian. Dentro de él, serializamos el value a JSON y lo guardamos en localStorage con la key proporcionada. También tiene manejo de errores y compatibilidad con SSR.
  4. return [value, setValue]: Devolvemos el valor actual del estado y la función para actualizarlo, haciendo que sea compatible con la sintaxis de desestructuración de useState.
🔥 Importante: La gestión de `typeof window === 'undefined'` es vital para aplicaciones React que utilizan **Server-Side Rendering (SSR)**. Sin esta comprobación, el código podría fallar al ejecutarse en el servidor, ya que `window` no está definido en ese entorno.

Usando useLocalStorage en un Componente 🧑‍💻

// src/App.js o cualquier componente
import React from 'react';
import useLocalStorage from './hooks/useLocalStorage';
import useToggle from './hooks/useToggle'; // Podemos combinar Hooks!

function UserSettings() {
  const [username, setUsername] = useLocalStorage('username', 'Invitado');
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [notificationsEnabled, toggleNotifications] = useToggle(true);

  const handleUsernameChange = (event) => {
    setUsername(event.target.value);
  };

  const handleThemeChange = (event) => {
    setTheme(event.target.value);
  };

  return (
    <div style={{
      padding: '20px',
      border: '1px solid #ddd',
      borderRadius: '8px',
      backgroundColor: theme === 'dark' ? '#333' : '#f9f9f9',
      color: theme === 'dark' ? '#f9f9f9' : '#333'
    }}>
      <h2>Configuración de Usuario</h2>
      <p>Bienvenido, **{username}**!</p>

      <label>
        Nombre de usuario:
        <input
          type="text"
          value={username}
          onChange={handleUsernameChange}
          style={{ marginLeft: '10px', padding: '5px', borderRadius: '4px', border: '1px solid #ccc' }}
        />
      </label>
      <br /><br />

      <label>
        Tema:
        <select
          value={theme}
          onChange={handleThemeChange}
          style={{ marginLeft: '10px', padding: '5px', borderRadius: '4px', border: '1px solid #ccc' }}
        >
          <option value="light">Claro</option>
          <option value="dark">Oscuro</option>
        </select>
      </label>
      <br /><br />

      <label>
        Notificaciones:
        <input
          type="checkbox"
          checked={notificationsEnabled}
          onChange={toggleNotifications}
          style={{ marginLeft: '10px' }}
        />
        {notificationsEnabled ? 'Activadas' : 'Desactivadas'}
      </label>

      <p>El tema y el nombre de usuario se guardan en el localStorage.</p>
      <p>Las notificaciones se guardan con `useToggle` (no persistente en este ejemplo).</p>
    </div>
  );
}

export default UserSettings;

Este ejemplo demuestra cómo useLocalStorage encapsula la lógica de persistencia, haciendo que el componente UserSettings sea muy legible y enfocado únicamente en la interfaz de usuario. También muestra cómo puedes combinar diferentes Hooks personalizados (useToggle y useLocalStorage) en un solo componente.


Otro Ejemplo: useDebounce ⏱️

El debouncing es una técnica común para optimizar el rendimiento, especialmente en entradas de texto (ej. barras de búsqueda) donde no queremos ejecutar una acción hasta que el usuario ha dejado de escribir por un cierto período de tiempo. Esto evita llamadas excesivas a APIs o recálculos costosos.

Implementando useDebounce

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

function useDebounce(value, delay) {
  // Estado interno para almacenar el valor 'debounced'
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // Configurar un temporizador que actualiza 'debouncedValue' después del 'delay'
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Función de limpieza que se ejecuta al desmontar o antes de cada nuevo 'useEffect'
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]); // Re-ejecutar el efecto si 'value' o 'delay' cambian

  return debouncedValue;
}

export default useDebounce;

Explicación:

  1. useState(value): El debouncedValue se inicializa con el value de entrada.
  2. useEffect:
    • Cuando value o delay cambian, se configura un setTimeout.
    • Después del delay, setDebouncedValue(value) se llama, actualizando el valor.
    • La función de limpieza return () => { clearTimeout(handler); } es crucial. Si value cambia antes de que expire el delay del setTimeout anterior, el temporizador anterior se limpia (cancelado) y se establece uno nuevo. Esto asegura que la lógica de debouncing funcione correctamente.

Usando useDebounce en un Componente 🔍

// src/SearchInput.js
import React, { useState, useEffect } from 'react';
import useDebounce from './hooks/useDebounce';

function SearchInput() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500); // 500ms de retraso

  useEffect(() => {
    // Simular una llamada a la API con el término de búsqueda debounced
    if (debouncedSearchTerm) {
      console.log(`Realizando búsqueda para: ${debouncedSearchTerm}`);
      // Aquí iría tu lógica de llamada a la API o filtrado de datos
      // fetchData(debouncedSearchTerm).then(results => setResults(results));
    } else {
      console.log('Término de búsqueda vacío, no se realiza ninguna acción.');
    }
  }, [debouncedSearchTerm]); // Solo se ejecuta cuando el término debounced cambia

  const handleInputChange = (event) => {
    setSearchTerm(event.target.value);
  };

  return (
    <div style={{ padding: '20px', border: '1px solid #eee', borderRadius: '8px' }}>
      <h2>Buscador Debounced</h2>
      <input
        type="text"
        placeholder="Escribe para buscar..."
        value={searchTerm}
        onChange={handleInputChange}
        style={{ width: '100%', padding: '10px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
      />
      <p>Término actual: **{searchTerm}**</p>
      <p>Término debounced (buscado): <mark>{debouncedSearchTerm || 'N/A'}</mark></p>
      <div class="progress-bar"><div class="progress-fill" style="width: 100%; background: #4CAF50;">Buscando...</div></div>
    </div>
  );
}

export default SearchInput;

Este ejemplo muestra cómo useDebounce te permite desacoplar la lógica de debouncing de tu componente de búsqueda, haciéndolo más limpio y fácil de entender. La llamada a la API (o cualquier acción costosa) solo se ejecuta una vez que el usuario ha dejado de escribir por 500 milisegundos.


Organización y Mejores Prácticas 📁

Para mantener tu proyecto escalable y organizado, considera estas prácticas:

  • Carpeta dedicada: Crea una carpeta hooks (o utils/hooks) en la raíz de tu src para almacenar todos tus Hooks personalizados.
  • Un Hook por archivo: Cada Hook personalizado debería residir en su propio archivo (ej. useMiHook.js).
  • Documentación: Comenta tus Hooks, especialmente si tienen lógica compleja o parámetros específicos. Usa JSDoc para una documentación más formal.
  • Testing: Asegúrate de testear tus Hooks. Puedes usar @testing-library/react-hooks o @testing-library/react para ello.
  • Nombres descriptivos: El nombre de tu Hook debe reflejar claramente su propósito (ej. useFetch, useForm, useHover).
📌 Nota: Los Hooks personalizados no son un reemplazo para las librerías de gestión de estado global como Redux o Zustand. Son más bien herramientas para encapsular la lógica local de un componente que puede ser compartida entre muchos, sin la necesidad de un almacén global.

Estructura de Proyecto Sugerida

src/
├── components/
│   ├── Button.js
│   └── Card.js
├── hooks/
│   ├── useToggle.js
│   ├── useLocalStorage.js
│   ├── useDebounce.js
│   └── useFetch.js
├── pages/
│   ├── HomePage.js
│   └── SettingsPage.js
├── App.js
└── index.js

Cuándo Crear un Hook Personalizado 🧐

No todos los problemas requieren un Hook personalizado. Aquí hay algunas señales de que podrías necesitar uno:

  • Lógica de estado repetida: Si copias y pegas useState y useEffect (o cualquier otra lógica de Hook) entre varios componentes.
  • Componentes complejos: Si un componente funcional tiene demasiada lógica (muchos useState, useEffect) y se vuelve difícil de leer y mantener.
  • Necesidad de abstraer efectos secundarios: Si tienes una lógica de efecto secundario compleja (ej. suscripciones a API, gestión de eventos del DOM) que quieres reutilizar.
  • Compartir comportamiento no visual: Cuando la lógica que quieres compartir no involucra la UI directamente, sino más bien el comportamiento y el estado.
Ejemplos de Hooks Personalizados Comunes
  • useFetch(url, options): Para gestionar llamadas a API, estados de carga y errores.
  • useForm(initialValues, validationSchema): Para manejar el estado y la validación de formularios.
  • useWindowSize(): Para obtener las dimensiones de la ventana y reaccionar a cambios.
  • useEventListener(eventType, handler, element): Para adjuntar y limpiar oyentes de eventos de forma segura.
  • useClickOutside(ref, handler): Para detectar clics fuera de un elemento específico (útil para modales, menús).

Testing de Hooks Personalizados ✅

Testear Hooks personalizados es crucial para asegurar su correcto funcionamiento. Podemos usar @testing-library/react-hooks (o si estamos en React 18+, simplemente @testing-library/react con renderHook).

El objetivo es simular el ciclo de vida de un componente que usa el Hook y verificar que la lógica de estado y efectos se comporta como se espera.

Ejemplo de Testing con useToggle

Primero, instala la librería de testing si no la tienes:

npm install --save-dev @testing-library/react-hooks # Para React <18
npm install --save-dev @testing-library/react # Para React >=18 (uso con renderHook)

Luego, crea un archivo de test (ej. useToggle.test.js):

// hooks/useToggle.test.js
import { renderHook, act } from '@testing-library/react';
import useToggle from './useToggle';

describe('useToggle', () => {
  it('should toggle boolean value', () => {
    const { result } = renderHook(() => useToggle(false));

    // El valor inicial debería ser false
    expect(result.current[0]).toBe(false);

    // Llamar a la función toggle
    act(() => {
      result.current[1](); // toggle()
    });
    // El valor debería ser true después del primer toggle
    expect(result.current[0]).toBe(true);

    act(() => {
      result.current[1](); // toggle()
    });
    // El valor debería volver a false después del segundo toggle
    expect(result.current[0]).toBe(false);
  });

  it('should accept an initial value', () => {
    const { result } = renderHook(() => useToggle(true));
    expect(result.current[0]).toBe(true);

    act(() => {
      result.current[1]();
    });
    expect(result.current[0]).toBe(false);
  });
});

Explicación:

  • renderHook: Esta función de @testing-library/react te permite renderizar un Hook en un componente de prueba aislado.
  • result.current: Contiene el valor actual retornado por tu Hook.
  • act(() => { ... }): Envuelve las actualizaciones de estado para asegurarte de que todas las actualizaciones se procesen antes de realizar aserciones. Esto simula cómo React gestiona los renderizados en un navegador.

Este enfoque garantiza que la lógica de tu Hook se comporta de manera predecible y robusta.


Conclusión ✨

Los React Hooks personalizados son una herramienta increíblemente potente en el arsenal de cualquier desarrollador React. Te permiten escribir código más limpio, modular y reutilizable, lo que se traduce en aplicaciones más fáciles de entender y mantener.

Al extraer la lógica de estado y efectos en funciones dedicadas, tus componentes se vuelven más ligeros y enfocados en su propósito principal: renderizar la UI. Empieza identificando patrones de lógica repetitiva en tus componentes y ¡conviértelos en tus propios Hooks personalizados! Verás cómo tu base de código se transforma positivamente.

¡Experimenta con ellos y eleva tu desarrollo React al siguiente nivel! 🚀

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!