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 personalizados | Con Hooks personalizados |
|---|---|
| Cada componente escribe su propia lógica de fetch + filtrado | Un solo usePosts() se reutiliza en todas partes |
| La lógica de datos y la lógica de UI están mezcladas | La lógica de datos es independiente, el componente solo se preocupa por la UI |
| Cambiar una lógica requiere modificar varios archivos | Cambia en un lugar y todos los componentes se actualizan sincronizados |
| Los componentes suelen superar fácilmente las 200+ líneas | Los componentes normalmente tienen < 80 líneas |
Convenciones de nomenclatura y reglas de los Hooks
| Convención | Descripción | Ejemplo |
|---|---|---|
conuseComienzo | React lo usa para identificar Hooks y aplicar reglas de lint | usePosts、useDarkMode |
| Colocar en el directorio hooks/ | Facilita la búsqueda y el mantenimiento | src/hooks/usePosts.js |
| Solo se puede llamar en el nivel superior del componente de función | No se puede llamar en condiciones/bucles/funciones anidadas | — |
| Devolver datos o métodos | Permite que quien llama obtenga el estado y la lógica internos del Hook | return { 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
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
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
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
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
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 ? '☀ Claro' : '☾ Oscuro'}
</button>
</nav>
</header>
)
}
export default NavBar
Variables CSS para cambiar de tema
Ejemplo
/* 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ística | Hook personalizado de React | Vue3 Composable |
|---|---|---|
| Convención de nomenclatura | Comienza con use | Comienza con use |
| Esencia | Función que utiliza Hooks de React | Función que utiliza las API de Vue3 |
| Reactividad | Se activa mediante el setter devuelto por useState | Se rastrea automáticamente mediante ref/reactive |
| Restricción de llamada | Solo en el nivel superior del componente de función | Solo en el nivel superior de <script setup> |
| Efectos secundarios | useEffect | watch / 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