React Hooks Complete Guide — Every Hook Explained with Real Examples

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

React Hooks replaced class components as the primary way to write React components. Understanding each hook's purpose, when to use it, and when to avoid it is the core skill that separates junior from senior React developers.

useState — Local Component State

useState stores values that, when changed, trigger a re-render of the component.

'use client'
 
import { useState } from 'react'
 
function SearchBar() {
  const [query, setQuery] = useState('')
  const [isLoading, setIsLoading] = useState(false)
 
  async function handleSearch(e: React.FormEvent) {
    e.preventDefault()
    setIsLoading(true)
    await performSearch(query)
    setIsLoading(false)
  }
 
  return (
    <form onSubmit={handleSearch}>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <button disabled={isLoading}>{isLoading ? 'Searching...' : 'Search'}</button>
    </form>
  )
}

Use the functional updater form when the new value depends on the previous value: setCount(prev => prev + 1).

useEffect — Side Effects

useEffect runs code after render. Use it for subscriptions, timers, and external system integrations.

'use client'
 
import { useEffect, useState } from 'react'
 
function UserPresence({ userId }: { userId: string }) {
  const [isOnline, setIsOnline] = useState(false)
 
  useEffect(() => {
    const channel = subscribeToPresence(userId, setIsOnline)
    // Cleanup prevents memory leaks on unmount
    return () => channel.unsubscribe()
  }, [userId]) // Re-run if userId changes
 
  return <span>{isOnline ? 'Online' : 'Offline'}</span>
}

useCallback — Stable Function References

useCallback returns a memoized function. Use it when passing callbacks to memoized child components to prevent unnecessary re-renders.

'use client'
 
import { useState, useCallback } from 'react'
import { ItemRow } from './item-row'
 
function ItemList({ items }: { items: Item[] }) {
  const [selected, setSelected] = useState<string | null>(null)
 
  // Without useCallback, a new function is created on every render
  const handleSelect = useCallback((id: string) => {
    setSelected(id)
  }, []) // No dependencies — function never changes
 
  return (
    <ul>
      {items.map(item => (
        <ItemRow key={item.id} item={item} onSelect={handleSelect} isSelected={selected === item.id} />
      ))}
    </ul>
  )
}

useMemo — Expensive Computations

useMemo caches the result of an expensive calculation and recomputes only when dependencies change.

'use client'
 
import { useMemo } from 'react'
 
function DataTable({ data, filter }: { data: Row[]; filter: string }) {
  const filteredData = useMemo(() => {
    return data
      .filter(row => row.name.toLowerCase().includes(filter.toLowerCase()))
      .sort((a, b) => a.name.localeCompare(b.name))
  }, [data, filter])
 
  return (
    <table>
      {filteredData.map(row => (
        <tr key={row.id}><td>{row.name}</td><td>{row.value}</td></tr>
      ))}
    </table>
  )
}

useRef — DOM Access and Mutable Values

useRef has two uses: accessing DOM elements directly, and storing mutable values that do not trigger re-renders.

'use client'
 
import { useRef, useEffect } from 'react'
 
function VideoPlayer({ src }: { src: string }) {
  const videoRef = useRef<HTMLVideoElement>(null)
  const timerRef = useRef<NodeJS.Timeout | null>(null)
 
  useEffect(() => {
    // Auto-play after 2 seconds
    timerRef.current = setTimeout(() => {
      videoRef.current?.play()
    }, 2000)
 
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current)
    }
  }, [])
 
  return <video ref={videoRef} src={src} controls />
}

useContext — Consuming Context

'use client'
 
import { createContext, useContext, useState } from 'react'
 
const ThemeContext = createContext<{ theme: string; toggle: () => void } | null>(null)
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  return (
    <ThemeContext.Provider value={{ theme, toggle: () => setTheme(t => t === 'light' ? 'dark' : 'light') }}>
      {children}
    </ThemeContext.Provider>
  )
}
 
export function useTheme() {
  const ctx = useContext(ThemeContext)
  if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
  return ctx
}

useReducer — Complex State Logic

Use useReducer when state transitions are complex or when next state depends on multiple previous values.

'use client'
 
import { useReducer } from 'react'
 
type State = { count: number; step: number }
type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'setStep'; step: number }
 
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment': return { ...state, count: state.count + state.step }
    case 'decrement': return { ...state, count: state.count - state.step }
    case 'setStep': return { ...state, step: action.step }
  }
}
 
function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 })
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+{state.step}</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-{state.step}</button>
    </div>
  )
}

useTransition and useDeferredValue — Concurrent Features

These hooks keep the UI responsive during expensive updates.

'use client'
 
import { useState, useTransition, useDeferredValue } from 'react'
 
function SearchPage() {
  const [query, setQuery] = useState('')
  const [isPending, startTransition] = useTransition()
  const deferredQuery = useDeferredValue(query)
 
  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value)
    // Heavy result update is non-urgent
    startTransition(() => {
      // expensive state updates go here
    })
  }
 
  return (
    <>
      <input onChange={handleChange} placeholder="Search..." />
      {isPending && <span>Updating...</span>}
      {/* deferredQuery lags behind query — keeps input snappy */}
      <SearchResults query={deferredQuery} />
    </>
  )
}

Common Mistakes

  • Forgetting the dependency array in useEffect, causing infinite re-render loops
  • Using useCallback and useMemo everywhere — they add overhead; only apply to proven bottlenecks
  • Mutating ref values and expecting a re-render — useRef changes do not trigger renders
  • Missing cleanup in useEffect subscriptions and timers, causing memory leaks
  • Calling hooks conditionally or inside loops — hooks must be called at the top level every render

Best Practices

  • Extract related state and logic into custom hooks for reusability and testability
  • Use the React DevTools Profiler to identify actual re-render problems before reaching for useMemo
  • Prefer useReducer over multiple useState calls when state values change together
  • Always specify all reactive dependencies in effect and callback dependency arrays — use the eslint-plugin-react-hooks rule

Key Takeaways

  • useState and useEffect cover 80% of real-world hook usage; master them first
  • useCallback and useMemo are performance tools, not correctness tools — measure before applying
  • useRef persists values across renders without triggering a re-render, and provides direct DOM access
  • useReducer scales better than multiple useState calls for complex, interdependent state
  • useTransition marks state updates as non-urgent, keeping input and animations smooth during heavy work
  • useDeferredValue delays rendering a value — similar to debouncing but integrated with React's scheduler
  • Always return cleanup functions from useEffect when working with subscriptions, timers, or event listeners
  • Custom hooks starting with use can call other hooks and encapsulate reusable stateful logic

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading