Custom Hooks — Encapsulating Reusable Logic

In this chapter, you will learn React best practices — using custom Hooks to encapsulate reusable logic, making components cleaner and easier to maintain.


What is a Custom Hook?

As features grow, useState and useEffect are scattered across components, and duplicated logic exists between different components.

Custom Hookextracts the combined logic of multiple Hooks into a function that starts with "use".

Its essence:A regular JS function that internally uses React Hooks。


Why Do You Need Custom Hooks?

Without Custom HooksWith Custom Hooks
Each component writes its own fetch + filter logicOne usePosts() is reused everywhere
Data logic and UI logic are mixed togetherData logic is independent, components only care about UI
Changing one piece of logic requires modifying multiple filesChange one place, all components update synchronously
Components often exceed 200+ linesComponents are usually < 80 lines

Naming Conventions and Hook Rules

RuleDescriptionExample
withusePrefixReact uses this to recognize Hooks and apply lint rulesusePosts、useDarkMode
Place in the hooks/ directoryFor easy lookup and maintenancesrc/hooks/usePosts.js
Can only be called at the top level of function componentsCannot be called inside conditions, loops, or nested functions—
Return data or methodsAllow the caller to access the state and logic inside the Hookreturn { data, isLoading }

Hook usage rules: 1. Only call Hooks at the top level of function components; 2. Only call Hooks in React functions (function components or custom Hooks). This ensures that the order of Hook calls is consistent on every render.


usePosts — Encapsulating Post Data Logic

Example

// File path: 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('All')
  const [keyword, setKeyword] = useState('')

  // Load data
  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 }
  }, [])

  // Extract all categories
  const categories = useMemo(() => {
    return ['All', ...new Set(articles.map(a => a.category))]
  }, [articles])

  // Filter by category + keyword
  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])

  // Find post by ID
  function getArticleById(id) {
    return articles.find(a => a.id === Number(id))
  }

  // Reload
  function refetch() {
    // Another way to trigger useEffect: use a key or enhance state
    window.location.reload()
  }

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

Home Page Using usePosts

Example

// File path: src/pages/HomePage.jsx
import { usePosts } from '../hooks/usePosts'
import BlogCard from '../components/BlogCard'
import CategoryFilter from '../components/CategoryFilter'

function HomePage() {
  // Get all post-related data and operations with one line of code
  const {
    isLoading, error,
    activeCategory, setActiveCategory,
    keyword, setKeyword,
    categories,
    filteredArticles,
    refetch
  } = usePosts()

  if (isLoading) return <p className="status-msg">Loading...</p>
  if (error) return (
    <div className="status-msg error">
      <p>Load failed:{error}</p>
      <button onClick={refetch}>Retry</button>
    </div>
  )

  return (
    <div>
      <h2 className="section-title">Latest Posts</h2>

      <div className="search-bar">
        <input
          type="text"
          value={keyword}
          onChange={e => setKeyword(e.target.value)}
          placeholder="Search post titles or summaries..."
          className="search-input"
        />
        {keyword && <span className="clear-btn" onClick={() => setKeyword('')}>✕</span>}
      </div>

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

      <p className="result-info">common{filteredArticles.length}article</p>

      {filteredArticles.length === 0 ? (
        <p className="empty-tip">No matching posts</p>
      ) : (
        <div className="article-grid">
          {filteredArticles.map(article => (
            <BlogCard key={article.id} {...article} />
          ))}
        </div>
      )}
    </div>
  )
}

export default HomePage

Detail Page Using usePosts

Example

// File path: 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">Loading...</p>
  if (error) return <p className="status-msg error">Load failed:{error}</p>

  if (!article) {
    return (
      <div className="not-found">
        <h2>Post not found</h2>
        <Link to="/">Back to Home</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">← Back to Home</Link>
    </article>
  )
}

export default PostPage

Note: If the user directly visits the detail page URL (e.g., refreshing the page), usePosts will re-fetch the data. This is correct behavior — because the usePosts instance on the home page and the usePosts instance on the detail page are independent, each calling its own useEffect.


useDarkMode — Switch to Dark Mode with One Line of Code

Example

// File path: src/hooks/useDarkMode.js
import { useState, useEffect } from 'react'

export function useDarkMode() {
  // Read the previous setting from localStorage
  const [isDark, setIsDark] = useState(() => {
    return localStorage.getItem('blog-theme') === 'dark'
  })

  // When isDark changes, sync to DOM and 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 }
}

Using it in NavBar:

Example

// File path: src/components/NavBar.jsx
import { useDarkMode } from '../hooks/useDarkMode'

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

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

export default NavBar

Implementing Theme Switching with CSS Variables

Example

/* File path: src/index.css (global styles) */
/* Light mode (default) */
:root {
  --bg-primary: #f5f5f5;
  --bg-card: #ffffff;
  --text-primary: #333333;
  --text-secondary: #666666;
  --border-color: #eeeeee;
}

/* Dark mode */
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 Custom Hooks vs Vue3 Composables

FeatureReact Custom HookVue3 Composable
Naming conventionStarts with "use"Starts with "use"
EssenceA function that uses React HooksA function that uses Vue3 APIs
ReactivityTriggered via the setter returned by useStateAutomatically tracked via ref/reactive
Call restrictionOnly at the top level of function componentsOnly at the top level of <script setup>
Side effectsuseEffectwatch / watchEffect / onMounted

Chapter Summary

In this chapter, you have mastered React's most important design pattern — custom Hooks: starting with "use", internally combining multiple React Hooks, and returning state and operations.

usePosts centrally manages data loading + filtering + search, and useDarkMode enables dark mode integration with one line of code. Components now only care about UI rendering.

Other Extensions