Пользовательские хуки — инкапсуляция переиспользуемой логики

В этой главе вы изучите лучшие практики React — использование пользовательских хуков для инкапсуляции переиспользуемой логики, что делает компоненты чище и проще в поддержке.


Что такое пользовательский хук?

По мере роста функциональности useState и useEffect оказываются разбросаны по компонентам, а между разными компонентами появляется дублирующаяся логика.

Пользовательский хукизвлекает объединённую логику нескольких хуков в функцию, имя которой начинается с "use".

Его суть:Обычная JS-функция, которая внутри использует React Hooks。


Зачем нужны пользовательские хуки?

Без пользовательских хуковС пользовательскими хуками
Каждый компонент пишет собственную логику fetch + filterОдин usePosts() переиспользуется везде
Логика данных и логика UI смешаныЛогика данных независима, компоненты заботятся только об UI
Изменение одной части логики требует изменения нескольких файловИзмените в одном месте — все компоненты обновляются синхронно
Компоненты часто превышают 200+ строкКомпоненты обычно < 80 строк

Соглашения об именовании и правила хуков

ПравилоОписаниеПример
withuseПрефиксReact использует это, чтобы распознавать хуки и применять правила lintusePosts、useDarkMode
Поместите в каталог hooks/Для удобного поиска и сопровожденияsrc/hooks/usePosts.js
Можно вызывать только на верхнем уровне функциональных компонентовНельзя вызывать внутри условий, циклов или вложенных функций—
Возвращают данные или методыПозволяют вызывающему коду получать доступ к состоянию и логике внутри хукаreturn { data, isLoading }

Правила использования хуков: 1. Вызывайте хуки только на верхнем уровне функциональных компонентов; 2. Вызывайте хуки только в React-функциях (функциональных компонентах или пользовательских хуках). Это гарантирует, что порядок вызовов хуков остаётся одинаковым при каждом рендере.


usePosts — Инкапсуляция логики данных постов

Пример

// Путь к файлу: 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('Все')
  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

Пример

// Путь к файлу: src/pages/HomePage.jsx
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

Пример

// Путь к файлу: 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">Загрузка...</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 — Переключение на тёмный режим одной строкой кода

Пример

// Путь к файлу: src/hooks/useDarkMode.js
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:

Пример

// Путь к файлу: src/components/NavBar.jsx
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 ? '&#x2600; Светлая' : '☾ Тёмная'}
        </button>
      </nav>
    </header>
  )
}

export default NavBar

Реализация переключения темы с помощью CSS-переменных

Пример

/* Путь к файлу: src/index.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>
Побочные эффектыuseEffectwatch / watchEffect / onMounted

Краткое содержание главы

В этой главе вы освоили самый важный шаблон проектирования React — пользовательские хуки: начиная с "use", внутри объединяя несколько хуков React и возвращая состояние и операции.

usePosts централизованно управляет загрузкой данных + фильтрацией + поиском, а useDarkMode позволяет подключить тёмный режим одной строкой кода. Компоненты теперь заботятся только о рендеринге UI.

Другие расширения