Custom Hooks Pitfalls

Serhii Shramko

Serhii Shramko /

4 min read--- views

Understanding the Hidden Pitfalls of Custom Hooks in React

In a previous post we covered How React Rerenders Works. But today, we'll take a closer look at the hidden pitfalls of custom hooks in React.

Hand-drawn diagram showing 'Custom React Hooks' as the main title with small hook illustrations, 'useEffect ( )' written in blue text on the left, and the React logo (atomic symbol) in blue on the bottom right.

When we handle state, component re-renders, and application performance in React, custom hooks are a powerful tool that help us keep our components clean and manageable. However, this simplicity can sometimes hide performance issues that we may not immediately notice.

Consider a basic custom hook for toggling the visibility of a tooltip:

const useTooltipToggle = () => {
  const [visible, setVisible] = useState(false);

  return {
    visible,
    show: () => setVisible(true),
    hide: () => setVisible(false),
  };
};

Using this hook in a component might look neat and clean:

const App = () => {
  const {
    visible,
    show,
    hide
  } = useTooltipToggle();

  return (
    <div className="app-container">
      <button onMouseEnter={show} onMouseLeave={hide}>Hover me!</button>
      {visible && <Tooltip text="Hello, Tooltip!" />}
      <HeavyComputationComponent />
      <AnotherIntensiveComponent />
    </div>
  );
};

At first glance, this setup appears elegant—state logic neatly wrapped in the useTooltipToggle hook. However, this approach hides a subtle performance issue: whenever the tooltip visibility state changes, React re-renders the entire App component, despite the state being limited to a small interaction.

Why? You can read more about react rerenders here.

Hidden States and Re-rendering Pitfalls

Let's extend our custom hook to listen for a scroll event:

const useTooltipToggle = () => {
  const [scrollPosition, setScrollPosition] = useState(window.scrollY);

  useEffect(() => {
    const handleScroll = () => setScrollPosition(window.scrollY);
    window.addEventListener('scroll', handleScroll);

    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return {
    // For example, this part of the code was simplified
    visible: false,
    show: () => {},
    hide: () => {},
  };
};

Now, every time the user scrolls, this hook updates the state internally. Even though the scrolling position isn't returned or directly used, it triggers state changes, causing the entire App to re-render unnecessarily.

Nested Hooks Can Multiply Issues

The problem worsens when custom hooks depend on other hooks indirectly. For example, consider another hook for tracking mouse movement:

const useMouseTracker = () => {
  const [mousePosition, setMousePosition] = useState({
    x: 0,
    y: 0
  });

  useEffect(() => {
    const updateMousePosition = (e) => setMousePosition({
      x: e.clientX,
      y: e.clientY
    });
    window.addEventListener('mousemove', updateMousePosition);

    return () => window.removeEventListener('mousemove', updateMousePosition);
  }, []);

  return null;
};

const useTooltipToggle = () => {
  useMouseTracker();

  return {
    // For example, this part of the code was simplified
    visible: false,
    show: () => {},
    hide: () => {},
  };
};

Even if useTooltipToggle does not directly use mouse position state, the App component still re-renders each time the mouse moves, since the state is updated inside the nested hook.

Hiding stateful logic within hooks doesn't remove their performance impact. The solution isn’t hiding complexity but smartly managing and isolating the state.

How to Solve This

To avoid such issues, encapsulate state logic in smaller components:

const Tooltip = () => {
  const {
    visible,
    show,
    hide
  } = useTooltipToggle();

  return (
    <>
      <button onMouseEnter={show} onMouseLeave={hide}>Hover me!</button>
      {visible && <Tooltip text="Hello, Tooltip!" />}
    </>
  );
};

This ensures that the minimal required area re-renders, preventing unnecessary updates across the entire app.

In this solution, the Tooltip logic moved down from App to the own component. I described this approach in this article.

Key Takeaway

The key lesson: carefully handle state encapsulation within custom hooks. Keep state management local and focused to optimize your React application's performance and responsiveness.

P.S Memoization and other performance optimizations will be covered in future posts.

Share it: