JSTAcademy
0 XP
Dashboard
Crash Courses
Custom Hooks
10 min
Masters+175 XP
Crash Courses · Masters

Custom Hooks

Extract and reuse stateful logic across components with custom hooks.
10 min read+175 XP on completionCert: React Crash Course
Tap any word in the text below to start reading from there.

Custom Hooks

When multiple components need the same stateful logic — data fetching, localStorage sync, debouncing — the right solution is a custom hook. Extract the logic once, reuse it everywhere.

The Pattern

A custom hook is just a JavaScript function that:

  1. Starts with use (so React can enforce the rules of hooks)
  2. Calls other hooks (useState, useEffect, built-in or custom)
  3. Returns values the component needs

useLocalStorage — Persisted State

import { useState, useEffect } from 'react'

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const stored = localStorage.getItem(key)
      return stored ? JSON.parse(stored) : initialValue
    } catch {
      return initialValue
    }
  })

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch { /* storage quota exceeded */ }
  }, [key, value])

  return [value, setValue] as const
}

// Usage — drop-in replacement for useState with persistence
function App() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')
  const [sidebarOpen, setSidebarOpen] = useLocalStorage('sidebar', true)
}

useFetch — Data Fetching with Loading State

function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    let cancelled = false

    fetch(url)
      .then(r => r.json())
      .then(d => { if (!cancelled) { setData(d); setError(null) } })
      .catch(e => { if (!cancelled) setError(e.message) })
      .finally(() => { if (!cancelled) setLoading(false) })

    return () => { cancelled = true }
  }, [url])

  return { data, loading, error }
}

// Usage — components get clean data with no loading boilerplate
function CourseDetail({ id }: { id: string }) {
  const { data: course, loading, error } = useFetch<Course>(`/api/courses/${id}`)

  if (loading) return <Spinner />
  if (error) return <ErrorMessage message={error} />
  return <CourseView course={course!} />
}

useDebounce — Delay Expensive Operations

function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer)  // cancel the previous timer on each change
  }, [value, delay])

  return debounced
}

// Usage — search fires only after 300ms of inactivity
function CourseSearch() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 300)

  useEffect(() => {
    if (debouncedQuery) searchCourses(debouncedQuery)
  }, [debouncedQuery])

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
      placeholder="Search courses..."
    />
  )
}

useMediaQuery — Responsive Logic in JS

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(
    () => window.matchMedia(query).matches
  )

  useEffect(() => {
    const mql = window.matchMedia(query)
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
    mql.addEventListener('change', handler)
    return () => mql.removeEventListener('change', handler)
  }, [query])

  return matches
}

// Usage
function NavBar() {
  const isMobile = useMediaQuery('(max-width: 768px)')
  return isMobile ? <MobileNav /> : <DesktopNav />
}

The key insight: hooks compose. useDebounce uses useState and useEffect. useFetch uses useState, useEffect, and could use useDebounce. You build complex, reusable behavior by composing simple primitives.

0%