ReactJanuary 21, 202613 min read

React Hooks: A Deep Dive

Rohit Kumar
Rohit Kumar
React, WordPress & Automation Expert

React Hooks changed everything. Before Hooks, you needed class components for state and lifecycle methods. Now, you can do everything with functions. If you've read my beginner's guide to React, you know the basics. Let's go deeper into Hooks and how to use them like a pro.

What Are Hooks?

Hooks are functions that let you "hook into" React features from function components. They were introduced in React 16.8, and honestly, they made React so much better. No more confusing this keyword, no more binding methods - just clean, readable code.

The beauty of Hooks is that they let you reuse stateful logic without changing your component hierarchy. No more wrapper hell!

useState - Your First Hook

useState is probably the Hook you'll use most. It lets you add state to function components:

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  )
}

The useState Hook returns an array with two elements: the current state value and a function to update it. You can call useState multiple times in one component:

function UserProfile() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [age, setAge] = useState(0)
  
  // Each state is independent
  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <input value={age} onChange={e => setAge(Number(e.target.value))} />
    </form>
  )
}

State Updates Are Async

Important: state updates are batched and asynchronous. If you need the previous state, use a function:

// Wrong - might not work as expected
setCount(count + 1)
setCount(count + 1)  // Still might only increment by 1

// Right - always works
setCount(prev => prev + 1)
setCount(prev => prev + 1)  // Will increment by 2

useEffect - Side Effects Made Easy

useEffect lets you perform side effects in function components. It replaces componentDidMount, componentDidUpdate, and componentWillUnmount:

import { useState, useEffect } from 'react'

function UserData() {
  const [user, setUser] = useState(null)
  
  useEffect(() => {
    // This runs after every render
    fetch('/api/user')
      .then(res => res.json())
      .then(data => setUser(data))
  }, [])  // Empty array = only run once
  
  if (!user) return <div>Loading...</div>
  return <div>Hello {user.name}</div>
}

The Dependency Array

The second argument to useEffect is crucial:

// No array - runs after EVERY render
useEffect(() => {
  console.log('Rendered!')
})

// Empty array - runs ONCE after first render
useEffect(() => {
  console.log('Mounted!')
}, [])

// With dependencies - runs when dependencies change
useEffect(() => {
  console.log('Count changed:', count)
}, [count])

Cleanup Functions

Return a cleanup function to avoid memory leaks:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Tick')
  }, 1000)
  
  // Cleanup function
  return () => {
    clearInterval(timer)
  }
}, [])

useContext - Share Data Without Props

Context lets you pass data through the component tree without manually passing props at every level:

import { createContext, useContext, useState } from 'react'

// Create context
const ThemeContext = createContext()

// Provider component
function App() {
  const [theme, setTheme] = useState('light')
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Header />
      <Main />
    </ThemeContext.Provider>
  )
}

// Consumer component
function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext)
  
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current: {theme}
    </button>
  )
}

useReducer - Complex State Logic

When useState gets complicated, use useReducer:

import { useReducer } from 'react'

const initialState = { count: 0, step: 1 }

function reducer(state, action) {
  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.payload }
    case 'reset':
      return initialState
    default:
      return state
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState)
  
  return (
    <div>
      <p>Count: {state.count}</p>
      <p>Step: {state.step}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <input 
        type="number" 
        value={state.step}
        onChange={e => dispatch({ 
          type: 'setStep', 
          payload: Number(e.target.value) 
        })}
      />
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  )
}

useMemo - Optimize Expensive Calculations

useMemo memoizes expensive calculations so they only rerun when dependencies change:

import { useState, useMemo } from 'react'

function ExpensiveComponent({ items }) {
  const [filter, setFilter] = useState('')
  
  // This only recalculates when items or filter changes
  const filteredItems = useMemo(() => {
    console.log('Filtering items...')
    return items.filter(item => 
      item.name.toLowerCase().includes(filter.toLowerCase())
    )
  }, [items, filter])
  
  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      {filteredItems.map(item => <div key={item.id}>{item.name}</div>)}
    </div>
  )
}

useCallback - Memoize Functions

useCallback returns a memoized version of a callback function:

import { useState, useCallback } from 'react'

function Parent() {
  const [count, setCount] = useState(0)
  
  // This function reference stays the same unless count changes
  const handleClick = useCallback(() => {
    console.log('Clicked with count:', count)
  }, [count])
  
  return <Child onClick={handleClick} />
}

// Child won't rerender unless onClick changes
const Child = React.memo(({ onClick }) => {
  console.log('Child rendered')
  return <button onClick={onClick}>Click</button>
})

useRef - Persist Values Without Rerenders

useRef gives you a mutable object that persists across renders:

import { useRef, useEffect } from 'react'

function TextInput() {
  const inputRef = useRef(null)
  
  useEffect(() => {
    // Focus the input on mount
    inputRef.current.focus()
  }, [])
  
  return <input ref={inputRef} type="text" />
}

// Store previous values
function Counter() {
  const [count, setCount] = useState(0)
  const prevCountRef = useRef()
  
  useEffect(() => {
    prevCountRef.current = count
  })
  
  const prevCount = prevCountRef.current
  
  return (
    <div>
      <p>Now: {count}, Before: {prevCount}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

Custom Hooks - Reuse Logic

Custom Hooks let you extract component logic into reusable functions. They're just functions that use other Hooks:

// useLocalStorage - persist state to localStorage
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const item = localStorage.getItem(key)
    return item ? JSON.parse(item) : initialValue
  })
  
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value))
  }, [key, value])
  
  return [value, setValue]
}

// Usage
function App() {
  const [name, setName] = useLocalStorage('name', '')
  
  return (
    <input 
      value={name} 
      onChange={e => setName(e.target.value)}
    />
  )
}

More Custom Hook Examples

// useDebounce - delay value updates
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value)
  
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)
    
    return () => clearTimeout(timer)
  }, [value, delay])
  
  return debouncedValue
}

// useFetch - fetch data with loading states
function useFetch(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  
  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err)
        setLoading(false)
      })
  }, [url])
  
  return { data, loading, error }
}

// useWindowSize - track window dimensions
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  })
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      })
    }
    
    window.addEventListener('resize', handleResize)
    return () => window.removeEventListener('resize', handleResize)
  }, [])
  
  return size
}

Rules of Hooks

There are two important rules you must follow:

  1. Only call Hooks at the top level. Don't call Hooks inside loops, conditions, or nested functions.
  2. Only call Hooks from React functions. Call them from function components or custom Hooks, not regular JavaScript functions.
// ❌ Wrong
function Bad() {
  if (condition) {
    const [value, setValue] = useState(0)  // Conditional Hook call
  }
}

// ✓ Right
function Good() {
  const [value, setValue] = useState(0)
  
  if (condition) {
    // Use the Hook's value conditionally instead
    setValue(10)
  }
}

Performance Tips

Common Mistakes to Avoid

1. Stale Closures

// Problem: count is stale in the interval
function Counter() {
  const [count, setCount] = useState(0)
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1)  // Always uses initial count value
    }, 1000)
    
    return () => clearInterval(timer)
  }, [])  // Empty deps = count never updates
  
  return <div>{count}</div>
}

// Solution: Use functional update
useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1)  // Always uses current count
  }, 1000)
  
  return () => clearInterval(timer)
}, [])

2. Unnecessary useEffect

// ❌ Don't do this
function SearchResults({ query }) {
  const [results, setResults] = useState([])
  
  useEffect(() => {
    setResults(filterResults(data, query))
  }, [query, data])
  
  return <div>{results.map(...)}</div>
}

// ✓ Just calculate directly
function SearchResults({ query }) {
  const results = filterResults(data, query)
  return <div>{results.map(...)}</div>
}

3. Forgetting Cleanup

// ❌ Memory leak
useEffect(() => {
  document.addEventListener('scroll', handleScroll)
  // No cleanup!
}, [])

// ✓ Proper cleanup
useEffect(() => {
  document.addEventListener('scroll', handleScroll)
  return () => {
    document.removeEventListener('scroll', handleScroll)
  }
}, [])

Final Thoughts

Hooks are powerful, but they take practice to master. Start with useState and useEffect, then gradually learn the others as you need them. Build custom Hooks to share logic between components. And always follow the Rules of Hooks!

Once you're comfortable with Hooks, you'll find React development much more enjoyable. Your code will be cleaner, more reusable, and easier to test. If you want to learn more about React performance optimization, check out my upcoming guide on modern JavaScript features that work great with Hooks.

Tags:ReactHooksJavaScript
Share this article:

Related Posts