How to Optimize Custom Hooks in React

React custom hooks are powerful tools for sharing stateful logic, but poorly written hooks can lead to performance bottlenecks, stale closures, and unnecessary component re-renders. This article provides a straightforward guide on how to optimize your React custom hooks. You will learn how to leverage memoization with useMemo and useCallback, manage dependency arrays effectively, return stable references, and design clean, high-performance hooks for your applications.

1. Memoize Returned Functions with useCallback

When a custom hook returns a function, that function is recreated on every render of the host component. If the consumer component passes this function down as a prop to memoized child components (e.g., those wrapped in React.memo), it will trigger unnecessary re-renders.

To prevent this, wrap any functions returned by your custom hook in useCallback:

import { useState, useCallback } from 'react';

export function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  // Optimized: This function reference remains stable
  const toggle = useCallback(() => {
    setValue((prev) => !prev);
  }, []);

  return [value, toggle];
}

2. Memoize Returned Objects and Arrays with useMemo

If your custom hook returns an object or an array, a new reference is created on every execution of the hook. If the consumer component uses this object in its own dependency arrays (such as in a useEffect), it will trigger side effects on every render.

Use useMemo to ensure the returned reference only changes when the actual values change:

import { useState, useMemo } from 'react';

export function useUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // Optimized: The returned object reference only changes when user or loading changes
  return useMemo(() => ({
    user,
    loading,
    setUser
  }), [user, loading]);
}

3. Keep Dependency Arrays Honest and Minimal

Incomplete dependency arrays in useEffect, useMemo, or useCallback inside your custom hooks can cause stale closures, where your hook references outdated state or props. Conversely, over-populated dependency arrays cause excessive executions.

// Avoid this:
const increment = useCallback(() => {
  setCount(count + 1);
}, [count]); // 'count' must be in dependencies

// Do this instead:
const increment = useCallback(() => {
  setCount((prev) => prev + 1);
}, []); // No dependencies required

4. Avoid Passing Unstable References as Arguments

If your custom hook accepts objects, arrays, or functions as arguments, the hook’s internal useEffect or useMemo blocks will re-run on every render if the parent component passes unmemoized inline values.

To safeguard your custom hook: * Deconstruct primitives from object arguments inside the hook so you can depend on stable values rather than the object reference itself. * Document that complex arguments passed to the hook should be memoized by the consumer using useMemo or useCallback.

// Inside the hook, depend on the primitive values rather than the config object itself
export function useFetch(config) {
  const { url, method } = config;

  useEffect(() => {
    // Fetch logic using url and method
  }, [url, method]); // Safe from unstable config object reference
}

5. Separate Concerns into Multiple Custom Hooks

A single, massive custom hook that manages multiple unrelated pieces of state will cause the host component to re-render whenever any of those state pieces change.

To optimize performance, split monolithic hooks into smaller, single-purpose hooks. This ensures that a component only subscribes to the specific state and logic it actually needs to render.