Hook personalizado — Encapsular lógica reutilizable

En este capítulo aprenderás las mejores prácticas de React: usar Hooks personalizados para encapsular lógica reutilizable, haciendo que los componentes sean más simples y fáciles de mantener.


¿Qué es un Hook personalizado?

A medida que aumentan las funcionalidades, useState y useEffect quedan dispersos en los componentes, y hay lógica repetida entre diferentes componentes.

Hook personalizadoEs extraer la lógica combinada de varios Hooks en una función que comienza con use.

Su esencia:Una función JS normal que internamente utiliza Hooks de React.。


¿Por qué necesitamos Hooks personalizados?

Sin Hooks personalizadosCon Hooks personalizados
Cada componente escribe su propia lógica de fetch + filtradoUn solo usePosts() se reutiliza en todas partes
La lógica de datos y la lógica de UI están mezcladasLa lógica de datos es independiente, el componente solo se preocupa por la UI
Cambiar una lógica requiere modificar varios archivosCambia en un lugar y todos los componentes se actualizan sincronizados
Los componentes suelen superar fácilmente las 200+ líneasLos componentes normalmente tienen < 80 líneas

Convenciones de nomenclatura y reglas de los Hooks

ConvenciónDescripciónEjemplo
conuseComienzoReact lo usa para identificar Hooks y aplicar reglas de lintusePosts、useDarkMode
Colocar en el directorio hooks/Facilita la búsqueda y el mantenimientosrc/hooks/usePosts.js
Solo se puede llamar en el nivel superior del componente de funciónNo se puede llamar en condiciones/bucles/funciones anidadas—
Devolver datos o métodosPermite que quien llama obtenga el estado y la lógica internos del Hookreturn { data, isLoading }

Reglas de uso de los Hooks: 1. Solo llama Hooks en el nivel superior de un componente de función; 2. Solo llama Hooks en funciones de React (componentes de función o Hooks personalizados). Esto asegura que el orden de llamada de los Hooks sea consistente en cada renderizado.


usePosts — Encapsular la lógica de datos de artículos

Ejemplo

// Ruta del archivo: src/hooks/usePosts.js
import { useState, useEffect, useMemo } from 'react'

export function usePosts() {
  const [articles, setArticles] = useState([])
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState(null)
  const [activeCategory, setActiveCategory] = useState('Todos')
  const [keyword, setKeyword] = useState('')

  // Cargar datos
  useEffect(() => {
    let cancelled = false
    async function fetchPosts() {
      setIsLoading(true)
      setError(null)
      try {
        const res = await fetch('/posts.json')
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        const data = await res.json()
        if (!cancelled) setArticles(data)
      } catch (err) {
        if (!cancelled) setError(err.message)
      } finally {
        if (!cancelled) setIsLoading(false)
      }
    }
    fetchPosts()
    return () => { cancelled = true }
  }, [])

  // Extraer todas las categorías
  const categories = useMemo(() => {
    return ['Todos', ...new Set(articles.map(a => a.category))]
  }, [articles])

  // Filtrar por categoría + palabra clave
  const filteredArticles = useMemo(() => {
    let result = articles
    if (activeCategory !== 'Todos') {
      result = result.filter(a => a.category === activeCategory)
    }
    if (keyword.trim()) {
      const kw = keyword.trim().toLowerCase()
      result = result.filter(a =>
        a.title.toLowerCase().includes(kw) ||
        a.summary.toLowerCase().includes(kw)
      )
    }
    return result
  }, [articles, activeCategory, keyword])

  // Buscar artículo por ID
  function getArticleById(id) {
    return articles.find(a => a.id === Number(id))
  }

  // Recargar
  function refetch() {
    // Otra forma de activar useEffect: usando key o mejorando el estado
    window.location.reload()
  }

  return {
    articles, isLoading, error,
    activeCategory, setActiveCategory,
    keyword, setKeyword,
    categories,
    filteredArticles,
    getArticleById,
    refetch
  }
}

Página de inicio usando usePosts

Ejemplo

// Ruta del archivo: src/pages/HomePage.jsx
import { usePosts } from '../hooks/usePosts'
import BlogCard from '../components/BlogCard'
import CategoryFilter from '../components/CategoryFilter'

function HomePage() {
  // Obtén todos los datos y operaciones relacionados con los artículos en una línea de código
  const {
    isLoading, error,
    activeCategory, setActiveCategory,
    keyword, setKeyword,
    categories,
    filteredArticles,
    refetch
  } = usePosts()

  if (isLoading) return <p className="status-msg">Cargando...</p>
  if (error) return (
    <div className="status-msg error">
      <p>Error de carga:{error}</p>
      <button onClick={refetch}>Reintentar</button>
    </div>
  )

  return (
    <div>
      <h2 className="section-title">Artículos recientes</h2>

      <div className="search-bar">
        <input
          type="text"
          value={keyword}
          onChange={e => setKeyword(e.target.value)}
          placeholder="Buscar el título o resumen del artículo..."
          className="search-input"
        />
        {keyword && <span className="clear-btn" onClick={() => setKeyword('')}>✕</span>}
      </div>

      <CategoryFilter
        categories={categories}
        activeCategory={activeCategory}
        onCategoryChange={setActiveCategory}
      />

      <p className="result-info">común{filteredArticles.length}artículo</p>

      {filteredArticles.length === 0 ? (
        <p className="empty-tip">No hay artículos que coincidan</p>
      ) : (
        <div className="article-grid">
          {filteredArticles.map(article => (
            <BlogCard key={article.id} {...article} />
          ))}
        </div>
      )}
    </div>
  )
}

export default HomePage

Página de detalle usando usePosts

Ejemplo

// Ruta del archivo: src/pages/PostPage.jsx
import { useParams, Link } from 'react-router-dom'
import { usePosts } from '../hooks/usePosts'

function PostPage() {
  const { id } = useParams()
  const { isLoading, error, getArticleById } = usePosts()
  const article = getArticleById(id)

  if (isLoading) return <p className="status-msg">Cargando...</p>
  if (error) return <p className="status-msg error">Error de carga:{error}</p>

  if (!article) {
    return (
      <div className="not-found">
        <h2>El artículo no existe</h2>
        <Link to="/">Volver al inicio</Link>
      </div>
    )
  }

  return (
    <article className="post-view">
      <span className="category-tag">{article.category}</span>
      <h1>{article.title}</h1>
      <time>{article.date}</time>
      <div className="content" dangerouslySetInnerHTML={{ __html: article.content }} />
      <Link to="/" className="back-link">← Volver al inicio</Link>
    </article>
  )
}

export default PostPage

Nota: Si el usuario accede directamente a la URL de la página de detalle (por ejemplo, al actualizar la página), usePosts volverá a hacer fetch de los datos. Este es el comportamiento correcto, porque la instancia de usePosts de la página de inicio y la instancia de usePosts de la página de detalle son independientes, y cada una llama a su propio useEffect.


useDarkMode — Cambiar al modo oscuro con una línea de código

Ejemplo

// Ruta del archivo: src/hooks/useDarkMode.js
import { useState, useEffect } from 'react'

export function useDarkMode() {
  // Leer la configuración anterior desde localStorage
  const [isDark, setIsDark] = useState(() => {
    return localStorage.getItem('blog-theme') === 'dark'
  })

  // Cuando isDark cambia, sincronizar con el DOM y localStorage
  useEffect(() => {
    if (isDark) {
      document.documentElement.classList.add('dark')
      localStorage.setItem('blog-theme', 'dark')
    } else {
      document.documentElement.classList.remove('dark')
      localStorage.setItem('blog-theme', 'light')
    }
  }, [isDark])

  function toggleDark() {
    setIsDark(!isDark)
  }

  return { isDark, toggleDark }
}

Usarlo en NavBar:

Ejemplo

// Ruta del archivo: src/components/NavBar.jsx
import { useDarkMode } from '../hooks/useDarkMode'

function NavBar() {
  const { isDark, toggleDark } = useDarkMode()

  return (
    <header className="navbar">
      <a href="/" className="logo">EJEMPLO Blog</a>
      <nav>
        <a href="/">Inicio</a>
        <button className="theme-btn" onClick={toggleDark}>
          {isDark ? '&#x2600; Claro' : '☾ Oscuro'}
        </button>
      </nav>
    </header>
  )
}

export default NavBar

Variables CSS para cambiar de tema

Ejemplo

/* Ruta del archivo: src/index.css (estilos globales) */
/* Modo claro (por defecto) */
:root {
  --bg-primary: #f5f5f5;
  --bg-card: #ffffff;
  --text-primary: #333333;
  --text-secondary: #666666;
  --border-color: #eeeeee;
}

/* Modo oscuro */
html.dark {
  --bg-primary: #1a1a2e;
  --bg-card: #16213e;
  --text-primary: #e0e0e0;
  --text-secondary: #a0a0a0;
  --border-color: #2a2a4a;
}

body {
  background: var(--bg-primary);
  color: var(--text-primary);
}

.card, .article-card {
  background: var(--bg-card);
  border: 1px solid var(--border-color);
}

Hook personalizado de React vs Composable de Vue3

CaracterísticaHook personalizado de ReactVue3 Composable
Convención de nomenclaturaComienza con useComienza con use
EsenciaFunción que utiliza Hooks de ReactFunción que utiliza las API de Vue3
ReactividadSe activa mediante el setter devuelto por useStateSe rastrea automáticamente mediante ref/reactive
Restricción de llamadaSolo en el nivel superior del componente de funciónSolo en el nivel superior de <script setup>
Efectos secundariosuseEffectwatch / watchEffect / onMounted

Resumen del capítulo

En este capítulo has dominado el patrón de diseño más importante de React: el Hook personalizado. Comienza con use, combina múltiples Hooks de React internamente y devuelve estado y operaciones.

usePosts gestiona de forma unificada la carga de datos + filtrado + búsqueda, y useDarkMode permite integrar el modo oscuro con una línea de código. A partir de ahora, los componentes solo se preocupan por el renderizado de la UI.

Otras extensiones