Пользовательские хуки — инкапсуляция переиспользуемой логики
В этой главе вы изучите лучшие практики React — использование пользовательских хуков для инкапсуляции переиспользуемой логики, что делает компоненты чище и проще в поддержке.
Что такое пользовательский хук?
По мере роста функциональности useState и useEffect оказываются разбросаны по компонентам, а между разными компонентами появляется дублирующаяся логика.
Пользовательский хукизвлекает объединённую логику нескольких хуков в функцию, имя которой начинается с "use".
Его суть:Обычная JS-функция, которая внутри использует React Hooks。
Зачем нужны пользовательские хуки?
| Без пользовательских хуков | С пользовательскими хуками |
|---|---|
| Каждый компонент пишет собственную логику fetch + filter | Один usePosts() переиспользуется везде |
| Логика данных и логика UI смешаны | Логика данных независима, компоненты заботятся только об UI |
| Изменение одной части логики требует изменения нескольких файлов | Измените в одном месте — все компоненты обновляются синхронно |
| Компоненты часто превышают 200+ строк | Компоненты обычно < 80 строк |
Соглашения об именовании и правила хуков
| Правило | Описание | Пример |
|---|---|---|
withuseПрефикс | React использует это, чтобы распознавать хуки и применять правила lint | usePosts、useDarkMode |
| Поместите в каталог hooks/ | Для удобного поиска и сопровождения | src/hooks/usePosts.js |
| Можно вызывать только на верхнем уровне функциональных компонентов | Нельзя вызывать внутри условий, циклов или вложенных функций | — |
| Возвращают данные или методы | Позволяют вызывающему коду получать доступ к состоянию и логике внутри хука | return { data, isLoading } |
Правила использования хуков: 1. Вызывайте хуки только на верхнем уровне функциональных компонентов; 2. Вызывайте хуки только в React-функциях (функциональных компонентах или пользовательских хуках). Это гарантирует, что порядок вызовов хуков остаётся одинаковым при каждом рендере.
usePosts — Инкапсуляция логики данных постов
Пример
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('Все')
const [keyword, setKeyword] = useState('')
// Загрузить данные
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 }
}, [])
// Извлечь все категории
const categories = useMemo(() => {
return ['Все', ...new Set(articles.map(a => a.category))]
}, [articles])
// Фильтровать по категории + ключевому слову
const filteredArticles = useMemo(() => {
let result = articles
if (activeCategory !== 'All') {
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])
// Найти пост по ID
function getArticleById(id) {
return articles.find(a => a.id === Number(id))
}
// Перезагрузить
function refetch() {
// Другой способ вызвать useEffect: использовать key или улучшить состояние
window.location.reload()
}
return {
articles, isLoading, error,
activeCategory, setActiveCategory,
keyword, setKeyword,
categories,
filteredArticles,
getArticleById,
refetch
}
}
Главная страница с использованием usePosts
Пример
import { usePosts } from '../hooks/usePosts'
import BlogCard from '../components/BlogCard'
import CategoryFilter from '../components/CategoryFilter'
function HomePage() {
// Получить все данные и операции, связанные с постами, одной строкой кода
const {
isLoading, error,
activeCategory, setActiveCategory,
keyword, setKeyword,
categories,
filteredArticles,
refetch
} = usePosts()
if (isLoading) return <p className="status-msg">Загрузка...</p>
if (error) return (
<div className="status-msg error">
<p>Ошибка загрузки:{error}</p>
<button onClick={refetch}>Повторить</button>
</div>
)
return (
<div>
<h2 className="section-title">Последние статьи</h2>
<div className="search-bar">
<input
type="text"
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="Поиск по заголовкам или кратким описаниям статей..."
className="search-input"
/>
{keyword && <span className="clear-btn" onClick={() => setKeyword('')}>✕</span>}
</div>
<CategoryFilter
categories={categories}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
/>
<p className="result-info">Всего{filteredArticles.length}статей</p>
{filteredArticles.length === 0 ? (
<p className="empty-tip">Нет подходящих постов</p>
) : (
<div className="article-grid">
{filteredArticles.map(article => (
<BlogCard key={article.id} {...article} />
))}
</div>
)}
</div>
)
}
export default HomePage
Страница подробностей с использованием usePosts
Пример
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">Загрузка...</p>
if (error) return <p className="status-msg error">Ошибка загрузки:{error}</p>
if (!article) {
return (
<div className="not-found">
<h2>Пост не найден</h2>
<Link to="/">Вернуться на главную</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">← Вернуться на главную</Link>
</article>
)
}
export default PostPage
Примечание: Если пользователь напрямую посещает URL страницы подробностей (например, обновляет страницу), usePosts повторно запросит данные. Это правильное поведение — потому что экземпляр usePosts на главной странице и экземпляр usePosts на странице подробностей независимы, каждый вызывает свой useEffect.
useDarkMode — Переключение на тёмный режим одной строкой кода
Пример
import { useState, useEffect } from 'react'
export function useDarkMode() {
// Прочитать предыдущую настройку из localStorage
const [isDark, setIsDark] = useState(() => {
return localStorage.getItem('blog-theme') === 'dark'
})
// Когда isDark изменяется, синхронизировать с DOM и 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 }
}
Использование в NavBar:
Пример
import { useDarkMode } from '../hooks/useDarkMode'
function NavBar() {
const { isDark, toggleDark } = useDarkMode()
return (
<header className="navbar">
<a href="/" className="logo">ПРИМЕР Блог</a>
<nav>
<a href="/">Главная</a>
<button className="theme-btn" onClick={toggleDark}>
{isDark ? '☀ Светлая' : '☾ Тёмная'}
</button>
</nav>
</header>
)
}
export default NavBar
Реализация переключения темы с помощью CSS-переменных
Пример
/* Светлая тема (по умолчанию) */
:root {
--bg-primary: #f5f5f5;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--border-color: #eeeeee;
}
/* Тёмная тема */
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);
}
Пользовательские хуки React против композаблов Vue3
| Характеристика | Пользовательский хук React | Композабл Vue3 |
|---|---|---|
| Соглашение об именовании | Начинается с "use" | Начинается с "use" |
| Суть | Функция, которая использует React Hooks | Функция, которая использует API Vue3 |
| Реактивность | Вызывается через сеттер, возвращаемый useState | Автоматически отслеживается через ref/reactive |
| Ограничение вызова | Только на верхнем уровне функциональных компонентов | Только на верхнем уровне <script setup> |
| Побочные эффекты | useEffect | watch / watchEffect / onMounted |
Краткое содержание главы
В этой главе вы освоили самый важный шаблон проектирования React — пользовательские хуки: начиная с "use", внутри объединяя несколько хуков React и возвращая состояние и операции.
usePosts централизованно управляет загрузкой данных + фильтрацией + поиском, а useDarkMode позволяет подключить тёмный режим одной строкой кода. Компоненты теперь заботятся только о рендеринге UI.
Другие расширения