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.
- Use the ESLint rule: Always enable and follow the
eslint-plugin-react-hooks/exhaustive-depsrule to catch missing dependencies. - Use functional state updates: Instead of adding a
state variable to your dependency array, use the functional updater form
of
setStateto remove the dependency entirely.
// 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 required4. 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.