React: Ganchos personalizados
Última atualização: 2026-10-26
1. O que você vai aprender
- Convenções e regras de nomenclatura para ganchos personalizados
- Definição dos parâmetros e valores de retorno dos hooks personalizados
- 6 ganchos personalizados práticos
- Princípios para a reutilização de hooks personalizados
2. A história de um requisito de reutilização de formulários
Padrões de reutilização para ganchos personalizados
flowchart TD
A[Repetitive Logic] --> B[Extract as useXxx]
B --> C[ComponentsA useXxx]
B --> D[ComponentsB useXxx]
B --> E[ComponentsC useXxx]
F[useXxxInside] --> G[useState]
F --> H[useEffect]
F --> I[useRef]
F --> J[useContext]
K[The Rule of Three] --> L{Number of logical repetitions?}
L -->|1 time| M[No extraction required]
L -->|2 times| N[Consider extracting]
L -->|3+ times| O[Must be extracted ✅]
style B fill:#e8f5e9,stroke:#2e7d32
style O fill:#c8e6c9,stroke:#2e7d32
(1) Problema: A mesma lógica se repete em 5 páginas
Bob precisa “recuperar as preferências do usuário do armazenamento local” em cinco páginas diferentes:
// Page A:Settings Page
function SettingsPage() {
const [prefs, setPrefs] = useState(() => {
const saved = localStorage.getItem('preferences')
return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
})
useEffect(() => {
localStorage.setItem('preferences', JSON.stringify(prefs))
}, [prefs])
// ... Page A specifically UI
}
// Page B:Dashboard
function Dashboard() {
const [prefs, setPrefs] = useState(() => {
const saved = localStorage.getItem('preferences')
return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
})
useEffect(() => {
localStorage.setItem('preferences', JSON.stringify(prefs))
}, [prefs])
// ... Page B specifically UI(Exactly the same logic!)
}
Problema: localStorage A lógica de leitura/gravação foi copiada e colada cinco vezes em cinco páginas. Se quisermos adicionar um recurso para “monitorar eventos de armazenamento”, teríamos que modificar cinco arquivos.
(2) Soluções para ganchos personalizados
// ---- Custom Hook:Packaging localStorage Reading and Writing ----
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key)
return saved ? JSON.parse(saved) : initialValue
} catch {
return initialValue
}
})
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (e) {
console.error('Save to localStorage Failure:', e)
}
}, [key, value])
return [value, setValue]
}
// ---- Use this consistently on all pages ----
function SettingsPage() {
const [prefs, setPrefs] = useLocalStorage('preferences', {
theme: 'light', lang: 'zh'
})
// ... Specifically UI
}
function Dashboard() {
const [prefs, setPrefs] = useLocalStorage('preferences', {
theme: 'light', lang: 'zh'
})
// ... Specifically UI
}
Vantagens: 20 linhas de lógica → 1 linha de código. Ao modificar a lógica, basta alterar um único arquivo: useLocalStorage.
3. Regras para ganchos personalizados
| Regra | Descrição | Consequências da violação |
|---|---|---|
Começando com use |
Identificando hooks pelos nomes das dependências do React | O linter não verifica esta regra; chamadas condicionais não geram erros |
| Chame apenas no nível superior | Não chame dentro de loops, condicionais ou blocos aninhados | Desalinhamento da cadeia de hooks, confusão de estado |
| Chame apenas dentro de componentes de função ou hooks personalizados | Não utilize dentro de funções comuns ou componentes de classe | Erro de tempo de execução: Chamada de hook inválida |
| Retorna dados/funções, não JSX | Hooks retornam lógica, componentes retornam interface do usuário | Viola a separação de interesses, dificultando a reutilização |
| Limpeza de efeitos colaterais | Função de limpeza no retorno do useEffect | Vazamentos de memória, acúmulo de ouvintes de eventos |
(1) Convenções de nomenclatura
// ✅ It is essential to use Introduction
function useOnlineStatus() { }
function useWindowSize() { }
function useDebounce() { }
// ❌ Not based on use Introduction → React Will not check Hooks Rules
function onlineStatus() { }
function fetchData() { }
(2) Projeto do valor de retorno
// Method 1:Return an array(like useState)
function useToggle(initial = false) {
const [value, setValue] = useState(initial)
const toggle = useCallback(() => setValue(v => !v), [])
return [value, toggle] // Array,Flexible Deconstruction of Names
}
// Method 2:Return Object(Greater clarity when there are multiple return values)
function useUser() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
return { user, loading, error } // Object,Property Names Are Clear
}
// Method 3:Returns a single value
function useDocumentTitle(title) {
useEffect(() => { document.title = title }, [title])
// No return value required
}
(3) Qualquer hook pode ser usado internamente
function useData(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(url)
.then(res => {
if (!res.ok) throw new Error('Request Failed')
return res.json()
})
.then(data => { if (!cancelled) { setData(data); setLoading(false) }})
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})
return () => { cancelled = true }
}, [url])
return { data, loading, error }
}
// Used internally useState + useEffect + useCallback(If necessary)
// Custom Hook You can combine any of the built-in features Hooks
4. 6 ganchos personalizados práticos
| Hook | Função | Hooks usados internamente | Valor de retorno |
|---|---|---|---|
useToggle |
Interruptor booleano | useState, useCallback |
{ value, toggle, setTrue, setFalse } |
useDebounce |
Entrada com estabilização de imagem | useState, useEffect |
debouncedValue |
useWindowSize |
Monitoramento do tamanho da janela | useState, useEffect |
{ width, height } |
useFetch |
Solicitação de dados de três estados | useState, useEffect, useCallback |
{ data, loading, error, refetch } |
useIntersectionObserver |
Detecção de visibilidade | useState, useEffect |
isIntersecting |
useMediaQuery |
Ponto de quebra responsivo | useState, useEffect |
matches |
(1) Hook 1: useToggle (botão de alternância)
▶ Experimente(2) Hook 2: useDebounce (anti-jitter)
▶ Experimente(3) Hook 3: useWindowSize (tamanho da janela)
▶ Experimente(4) Hook 4: useFetch (solicitação de dados)
function useFetch(url, options = {}) {
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, options)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return res.json()
})
.then(data => { if (!cancelled) { setData(data); setLoading(false) }})
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})
return () => { cancelled = true }
}, [url])
// Resubmit Request
const refetch = useCallback(() => {
setLoading(true)
fetch(url, options)
.then(res => res.json())
.then(data => { setData(data); setLoading(false) })
.catch(err => { setError(err.message); setLoading(false) })
}, [url])
return { data, loading, error, refetch }
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error, refetch } = useFetch(`/api/users/${userId}`)
if (loading) return <p>Loading......</p>
if (error) return <p>Error:{error} <button onClick={refetch}>Retry</button></p>
return <h1>{user.name}</h1>
}
(5) Hook 5: useIntersectionObserver (Detecção de visibilidade)
function useIntersectionObserver(ref, options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false)
useEffect(() => {
if (!ref.current) return
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting)
}, { threshold: options.threshold || 0.1, ...options })
observer.observe(ref.current)
return () => observer.disconnect()
}, [ref, options.threshold])
return isIntersecting
}
// Usage:Lazy Loading of Images
function LazyImage({ src, alt }) {
const imgRef = useRef(null)
const isVisible = useIntersectionObserver(imgRef)
const [loaded, setLoaded] = useState(false)
return (
<div ref={imgRef} style={{ minHeight: '200px', backgroundColor: '#f5f5f5' }}>
{isVisible && (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ width: '100%', opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
)}
</div>
)
}
(6) Hook 6: useMediaQuery (Pontos de quebra responsivos)
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
useEffect(() => {
const mql = window.matchMedia(query)
function handler(e) { setMatches(e.matches) }
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [query])
return matches
}
// Usage
function ResponsiveLayout() {
const isMobile = useMediaQuery('(max-width: 768px)')
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
const isDesktop = useMediaQuery('(min-width: 1025px)')
return (
<div>
{isMobile && <MobileMenu />}
{isDesktop && <FullSidebar />}
<p>
{isMobile ? '📱 Cell Phone' : isTablet ? '📟 Tablet' : '💻 Computer'}
</p>
</div>
)
}
Saída:
Estado: query. Componente: useDebounce
5. Princípios para reutilização de hooks personalizados
| Princípio | Explicação | Exemplo incorreto | Exemplo correto |
|---|---|---|---|
| Responsabilidade Única | Um hook faz apenas uma coisa | useUserAndTheme() |
useUser() + useTheme() |
| Parâmetros flexíveis | Forneça valores padrão razoáveis | useFetch('/api/data') URL codificada |
useFetch(url) URL parametrizada |
| Valores de retorno completos | Retorna três estados: carregando, erro e dados | useFetch() Retorna apenas dados |
useFetch() Retorna { data, loading, error } |
| Limpeza de recursos | Limpa os efeitos colaterais quando o componente é desmontado | Não limpa temporizadores/ouvintes de eventos | Limpeza retornada por useEffect |
| Modular | Ganchos pequenos podem ser combinados para formar ganchos maiores | useComplexData() 100 linhas |
Combinação useFetch() + useDebounce() |
▶ Exemplo: Combinando vários ganchos pequenos
// ============================================
// Example:Combine multiple small Hook Building Complex Search Features
// ============================================
// 1. Small hook:Input Stabilization
function useDebounce(value, delay) { /* ... */ }
// 2. Small hook:Data Request
function useFetch(url) { /* ... */ }
// 3. Small hook:Local Storage
function useLocalStorage(key, initial) { /* ... */ }
// 4. Combined into a large Hook:Search
function useSearch(defaultQuery = '') {
const [query, setQuery] = useState(defaultQuery)
const [history, setHistory] = useLocalStorage('search_history', [])
const debouncedQuery = useDebounce(query, 500)
const { data, loading, error } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
)
function search(newQuery) {
setQuery(newQuery)
// Save Search History
if (newQuery.trim()) {
setHistory(prev => [newQuery, ...prev.filter(h => h !== newQuery)].slice(0, 10))
}
}
return { query, setQuery: search, results: data, loading, error, history }
}
// useSearch Combined internally useState + useDebounce + useFetch + useLocalStorage
Saída:
O hook customizado retorna { data, loading, error }. Uso: const { data, loading } = useFetch("/api/users"). Busca automaticamente na montagem.
6. Exemplo completo: Galeria de imagens (combinando vários ganchos personalizados)
// ============================================
// Complete Example:Photo Gallery(Combination 3 Custom Hook)
// ============================================
import { useState, useEffect, useCallback, useRef } from 'react'
// ---- Custom Hook 1:Infinite Scrolling ----
function useInfiniteScroll(callback) {
const sentinelRef = useRef(null)
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) callback()
}, { threshold: 0.1 })
observer.observe(sentinel)
return () => observer.disconnect()
}, [callback])
return sentinelRef
}
// ---- Custom Hook 2:Keyboard Navigation ----
function useKeyPress(targetKey, callback) {
useEffect(() => {
function handler(e) {
if (e.key === targetKey) callback(e)
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [targetKey, callback])
}
// ---- Custom Hook 3:Click here ----
function useClickOutside(callback) {
const ref = useRef(null)
useEffect(() => {
function handler(e) {
if (ref.current && !ref.current.contains(e.target)) {
callback()
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [callback])
return ref
}
// ---- Image Gallery Component ----
function ImageGallery() {
const [images, setImages] = useState([])
const [page, setPage] = useState(1)
const [selected, setSelected] = useState(null)
const [loading, setLoading] = useState(false)
// Loading image
const loadMore = useCallback(() => {
if (loading) return
setLoading(true)
const newImages = Array.from({ length: 8 }, (_, i) => ({
id: images.length + i + 1,
url: `https://picsum.photos/seed/${images.length + i + 1}/300/200`,
title: `Image ${images.length + i + 1}`
}))
setTimeout(() => {
setImages(prev => [...prev, ...newImages])
setPage(p => p + 1)
setLoading(false)
}, 800)
}, [loading, images.length])
// First Load
useEffect(() => { loadMore() }, [])
// Infinite Scroll: Load More
const sentinelRef = useInfiniteScroll(loadMore)
// Click here to close the preview
const previewRef = useClickOutside(() => setSelected(null))
// Escape Key to close the preview
useKeyPress('Escape', () => setSelected(null))
// Use the left and right arrow keys to switch between images
useKeyPress('ArrowLeft', () => {
if (selected) {
const idx = images.findIndex(i => i.id === selected.id)
if (idx > 0) setSelected(images[idx - 1])
}
})
useKeyPress('ArrowRight', () => {
if (selected) {
const idx = images.findIndex(i => i.id === selected.id)
if (idx < images.length - 1) setSelected(images[idx + 1])
}
})
return (
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
<h2>🖼️ Photo Gallery</h2>
<p style={{ color: '#666', fontSize: '13px' }}>Loaded {images.length} images | Keyboard:← Previous → Next Escape Close</p>
{/* Image Grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px' }}>
{images.map(img => (
<div key={img.id} onClick={() => setSelected(img)}
style={{ cursor: 'pointer', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<img src={img.url} alt={img.title}
style={{ width: '100%', height: '150px', objectFit: 'cover', transition: 'transform 0.3s' }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.05)'}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'} />
<p style={{ padding: '8px', margin: 0, fontSize: '13px', textAlign: 'center' }}>{img.title}</p>
</div>
))}
</div>
{/* Load More Triggers */}
<div ref={sentinelRef} style={{ textAlign: 'center', padding: '20px' }}>
{loading ? <p>⏳ Loading......</p> : <p style={{ color: '#999' }}>↓ Scroll to load more</p>}
</div>
{/* Image Preview(Click here to close) */}
{selected && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.8)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000
}}>
<div ref={previewRef} style={{ maxWidth: '80vw', maxHeight: '80vh' }}>
<img src={selected.url.replace('300/200', '600/400')} alt={selected.title}
style={{ maxWidth: '100%', maxHeight: '80vh', borderRadius: '8px' }} />
<p style={{ color: 'white', textAlign: 'center', marginTop: '12px' }}>
{selected.title}(Click here to close)
</p>
</div>
</div>
)}
</div>
)
}
Saída:
Estado: query (setter: setQuery)
Resultado esperado: Uma galeria de imagens que carrega 8 imagens na abertura inicial; ao rolar até o final da página, mais imagens são carregadas automaticamente; clicar em uma imagem abre uma pré-visualização (com suporte à navegação por teclado); clicar fora da área de pré-visualização fecha a janela.
▶ Exemplo 2: Hook useClipboard — Copiar para a área de transferência
function useClipboard() {
const [copied, setCopied] = useState(false)
async function copy(text) {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
setCopied(false)
}
}
return { copied, copy }
}
function CopyButton({ text }) {
const { copied, copy } = useClipboard()
return (
<button onClick={() => copy(text)}
style={{ padding: '4px 12px', cursor: 'pointer', background: copied ? '#52c41a' : '#f0f0f0', color: copied ? 'white' : '#333', border: 'none', borderRadius: 4 }}>
{copied ? 'Copied!' : 'Copy'}
</button>
)
}
Saída:
Hook useClipboard: clique para copiar o texto → feedback "Copied!" → reset automático após o tempo limite. Encapsula a API navigator.clipboard
Saída:
useOnlineStatus: "Online" (verde) / "Offline" (vermelho). Escuta os eventos do navigator.onLine
▶ Exemplo 3: Hook useOnlineStatus — Detecção do status da rede
▶ Experimente
Saída:
Hook useOnlineStatus: exibe "Online" (verde) ou "Offline" (vermelho). Escuta o navigator.onLine e os eventos online/offline
Saída:
Estado: isOnline (setter: setIsOnline). useEffect gerencia efeitos colaterais
▶ Exemplo 4: Hook useLocalStorage — Persistência de estado
▶ ExperimenteSaída:
useLocalStorage("theme", "light") → persiste entre recarregamentos. Alternar → "dark". Reabrir a página restaura o valor salvo.
Saída:
Estado: timeleft, isrunning. Botões: start, pause, resume. Usa useeffect
▶ Exemplo 5: Hook useCountdown — Contagem regressiva
▶ ExperimenteSaída:
Componente CountdownTimer com estado: seconds, autostart. Renderiza uma interface interativa.
❓ Perguntas Frequentes
useState e useEffect) dentro de um hook personalizado, mas não dentro de uma função comum. Portanto, você deve usar um hook personalizado apenas para “lógica reutilizável que requer Hooks”, enquanto uma função comum é suficiente para “lógica computacional pura”. Por exemplo, formatDate() usa uma função comum, enquanto useWindowSize() usa um hook personalizado.@testing-library/react-hooks. Testar hooks personalizados é igual a testar um componente — renderize o hook, verifique o valor de retorno, acione uma atualização e verifique as alterações. Para hooks puramente lógicos, o teste é muito simples.useEffect em um Hook personalizado?useState, useEffect, useRef, useContext, etc.). Por exemplo, o hook useFetch usa useState internamente para gerenciar dados, carregamento e estados de erro; useEffect para buscar dados na montagem; e useRef para armazenar o AbortController. Esse é exatamente o valor dos hooks personalizados: combinar vários hooks em uma única unidade de lógica reutilizável.📖 Resumo
- Um gancho personalizado é uma função que começa com
use; qualquer gancho embutido pode ser usado dentro dela. - O valor de retorno pode ser um array (como
useState), um objeto (vários valores de retorno) ou um único valor - 6 ganchos práticos: useToggle, useDebounce, useWindowSize, useFetch, useIntersectionObserver, useMediaQuery
- Siga a “Regra dos Três” — se um trecho de código for reutilizado, logicamente, três ou mais vezes, extraia-o para um hook
- Ganchos pequenos podem ser combinados para formar ganchos maiores, seguindo o Princípio da Responsabilidade Única
📝 Exercícios
- Problema básico (Dificuldade ⭐): Crie um hook
useCounterque retorne{ count, increment, decrement, reset }, com um valor inicial configurável. - Problema avançado (Dificuldade ⭐⭐): Crie um hook
useClipboardque encapsule a APInavigator.clipboarde retorne{ copied, copy(text) }, com suporte para copiar texto para a área de transferência. - Desafio (Dificuldade: ⭐⭐⭐): Crie um hook
useWebSocketque encapsule uma conexão WebSocket e retorne{ data, isConnected, send(message), reconnect() }, com suporte à reconexão automática e à detecção de heartbeat.