TUTORIAL

React Hooks Part 4: useCallback, useMemo, and Custom Hooks

The final part covers performance optimisation with useCallback and useMemo, then shows you how to extract reusable logic into custom hooks — the most powerful pattern React Hooks enable.


SERIES

React Hooks: A Complete Guide

A four-part deep-dive into React Hooks — from managing state with useState to squeezing performance out of useCallback and useMemo. Each article is standalone yet builds on the previous, giving you both quick reference and progressive mastery.

Table of Contents

Why performance hooks exist

Every time a React component re-renders, all values inside it are recreated — including objects and functions. This is usually fine. But when a memoized child receives a new function reference each render, it sees the prop as "changed" and re-renders too, even if the function body is identical.

useCallback and useMemo let you memoize a value so React reuses the same reference.


useCallback — stable function references

JSX
const memoizedFn = useCallback(() => {
  doSomething(a, b)
}, [a, b])

The function is recreated only when a or b changes.

The concrete problem it solves

JSX
// Without useCallback — handleSubmit is a new function every render
function Parent() {
  const [value, setValue] = useState('')
  const handleSubmit = () => submitForm(value)  // new ref each render

  return (
    <>
      <input value={value} onChange={e => setValue(e.target.value)} />
      <HeavyForm onSubmit={handleSubmit} />
    </>
  )
}

HeavyForm (wrapped in React.memo) re-renders on every keystroke. With useCallback:

JSX
const handleSubmit = useCallback(() => submitForm(value), [value])

Now HeavyForm only re-renders when value changes.

Rule of thumb

Only add useCallback when:

  1. You're passing the function to a memoized child, or
  2. The function is a dependency of another hook.

Adding it everywhere is premature optimisation.


useMemo — cached computed values

JSX
const expensiveResult = useMemo(() => {
  return computeExpensiveValue(input)
}, [input])

The factory runs only when input changes.

Filtering a large list

JSX
function ProductCatalog({ products, query }) {
  const filtered = useMemo(
    () => products.filter(p =>
      p.name.toLowerCase().includes(query.toLowerCase())
    ),
    [products, query]
  )

  return (
    <ul>
      {filtered.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}

Without useMemo, this filter runs on every render even when only the theme changes.

useCallback vs useMemo

JSX
// useCallback — memoizes the function itself
const fn  = useCallback(() => compute(x), [x])

// useMemo — memoizes the return value of calling the function
const val = useMemo(() => compute(x), [x])

// They are equivalent for functions:
const fn2 = useMemo(() => () => compute(x), [x])

Custom Hooks — the real game-changer

Custom hooks are plain functions whose names start with use and can call other hooks. They let you extract stateful logic out of components so it can be reused and tested independently.

useLocalStorage

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

  const setValueAndStore = newValue => {
    setValue(newValue)
    window.localStorage.setItem(key, JSON.stringify(newValue))
  }

  return [value, setValueAndStore]
}

// Drop-in replacement for useState
const [theme, setTheme] = useLocalStorage('theme', 'light')

useFetch

JSX
function useFetch(url) {
  const [data, setData]       = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError]     = useState(null)

  useEffect(() => {
    let cancelled = false
    setLoading(true)

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

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

  return { data, loading, error }
}

// Usage
function ArticleList() {
  const { data, loading, error } = useFetch('/api/articles')
  if (loading) return <Spinner />
  if (error)   return <ErrorMessage message={error} />
  return <ul>{data.map(a => <li key={a.id}>{a.title}</li>)}</ul>
}

useWindowSize

JSX
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  })

  useEffect(() => {
    const handle = () =>
      setSize({ width: window.innerWidth, height: window.innerHeight })

    window.addEventListener('resize', handle)
    return () => window.removeEventListener('resize', handle)
  }, [])

  return size
}

const { width } = useWindowSize()
const isMobile  = width < 768

The custom hook contract

Any function that:

  • Starts with use
  • Can call React hooks inside it

…is a valid custom hook. No class hierarchy, no HOC boilerplate, no render props — just a function.


Series wrap-up

You now have a complete toolkit:

Hook Job
useState Local component state
useEffect Side effects after render
useRef Mutable value, DOM access
useReducer Complex state machines
useCallback Stable function reference
useMemo Cached computed value
Custom hooks Extract and reuse stateful logic

The real skill is knowing which tool fits the job — not reaching for useMemo on every computed value, and not reaching for useReducer when a simple useState is clear enough.


Was this article helpful?

w

webencher Editorial

Software engineers and technical writers with 10+ years of combined experience in algorithms, systems design, and web development. Every article is reviewed for accuracy, depth, and practical applicability.

More by this author →