DEV Community

Boris Serdiuk
Boris Serdiuk

Posted on

Popular patterns and anti-patterns with React Hooks

It's been more than 2 years since Hooks API was added to React. Many projects already adopted the new API and there was enough time to see how the new patterns work in production. In this article I am going to walk you through my list of learnings after maintaining a large hooks-based codebase.

Learning #1. All standard rules apply

Hooks require developers to learn new patterns and follow some rules of hooks. This sometimes makes people think that new pattern dismisses all previous good practices. However, hooks are just yet another way of creating reusable building blocks. If you are creating a custom hook, you still need to apply basic software development practices:

  1. Single-responsibility principle. One hook should encapsulate a single piece of functionality. Instead of creating a single super-hook, it is better to split it into multiple smaller and independent ones
  2. Clearly defined API. Similar to normal functions/methods, if a hook takes too many arguments, it is a signal that this hook needs refactoring to be better encapsulated. There were recommendations of avoiding React components having too many props, same for React hooks – they also should have minimal number of arguments.
  3. Predictable behavior. The name of a hook should correspond to its functionality, no additional unexpected behaviours.

Even though these recommendations may look very obvious, it is still important to ensure that you follow them when you are creating your custom hooks.

Learning #2. Dealing with hook dependencies.

Several React hooks introduce a concept of "dependencies" – list of things which should cause a hook to update. Most often this can be seen in useEffect, but also in useMemo and useCallback. There is a ESLint rule to help you managing an array of dependencies in your code, however this rule can only check the structure of the code and not your intent. Managing hook dependencies is the most tricky concept and requires a lot of attention from a developer. To make your code more readable and maintainable, you could reduce the number of hook dependencies.

Your hooks-based code could become easier with this simple trick. For example, let's consider a custom hook useFocusMove:

function Demo({ options }) {
  const [ref, handleKeyDown] = useFocusMove({
    isInteractive: (option) => !option.disabled,
  });
  return (
    <ul onKeyDown={handleKeyDown}>
      {options.map((option) => (
        <Option key={option.id} option={option} />
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

This custom hook takes a dependency on isInteractive, which can be used inside the hook implementation:

function useFocusMove({ isInteractive }) {
  const [activeItem, setActiveItem] = useState();

  useEffect(() => {
    if (isInteractive(activeItem)) {
      focusItem(activeItem);
    }
    // update focus whenever active item changes
  }, [activeItem, isInteractive]);

  // ...other implementation details...
}
Enter fullscreen mode Exit fullscreen mode

ESLint rule requires isInteractive argument to be added to useEffect dependencies, because the rule does not know where this custom hook is used and if this argument is ever changing or not. However, as a developer, we know that once defined this function always has the same implementation and adding it to the dependencies array only clutters the code. Standard "factory function" pattern comes to the rescue:

function createFocusMove({ isInteractive }) {
  return function useFocusMove() {
    const [activeItem, setActiveItem] = useState();

    useEffect(() => {
      if (isInteractive(activeItem)) {
        focusItem(activeItem);
      }
    }, [activeItem]); // no ESLint rule violation here :)

    // ...other implementation details...
  };
}

// usage
const useFocusMove = createFocusMove({
  isInteractive: (option) => !option.disabled,
});
function Demo({ options }) {
  const [ref, handleKeyDown] = useFocusMove();
  // ...other code unchanged...
}
Enter fullscreen mode Exit fullscreen mode

The trick here is to separate run-time and develop-time parameters. If something is changing during component lifetime, it is a run-time dependency and goes to the dependencies array. If it is once decided for a component and never changes in runtime, it is a good idea to try factory function pattern and make hooks dependencies management easer.

Learning #3. Refactoring useEffect

useEffect hook us a place for imperative DOM interactions inside your React components. Sometimes they could become very complex and adding dependencies array on top of that makes it more difficult tor read and maintain the code. This could be solved via extracting the imperative DOM logic outside the hook code. For example, consider a hook useTooltipPlacement:

function useTooltipPosition(placement) {
  const tooltipRef = useRef();
  const triggerRef = useRef();
  useEffect(() => {
    if (placement === "left") {
      const triggerPos = triggerRef.current.getBoundingElementRect();
      const tooltipPos = tooltipPos.current.getBoundingElementRect();
      Object.assign(tooltipRef.current.style, {
        top: triggerPos.top,
        left: triggerPos.left - tooltipPos.width,
      });
    } else {
      // ... and so on of other placements ...
    }
  }, [tooltipRef, triggerRef, placement]);
  return [tooltipRef, triggerRef];
}
Enter fullscreen mode Exit fullscreen mode

The code inside useEffect is getting very long and hard to follow and track if the hook dependencies are used properly. To make this simper, we could extract the effect content into a separate function:

// here is the pure DOM-related logic
function applyPlacement(tooltipEl, triggerEl, placement) {
  if (placement === "left") {
    const triggerPos = tooltipEl.getBoundingElementRect();
    const tooltipPos = triggerEl.getBoundingElementRect();
    Object.assign(tooltipEl.style, {
      top: triggerPos.top,
      left: triggerPos.left - tooltipPos.width,
    });
  } else {
    // ... and so on of other placements ...
  }
}

// here is the hook binding
function useTooltipPosition(placement) {
  const tooltipRef = useRef();
  const triggerRef = useRef();
  useEffect(() => {
    applyPlacement(tooltipRef.current, triggerRef.current, placement);
  }, [tooltipRef, triggerRef, placement]);
  return [tooltipRef, triggerRef];
}
Enter fullscreen mode Exit fullscreen mode

Our hook has become one line long and easy to track the dependencies. As a side bonus we also got a pure DOM implementation of the positioning which could be used and tested outside of React :)

Learning #4. useMemo, useCallback and premature optimisations

useMemo hook documentation says:

You may rely on useMemo as a performance optimisation

For some reason, developers read this part as "you must" instead of "you may" and attempt to memoize everything. This may sound like a good idea on a quick glance, but it appears to be more tricky when it comes to details.

To make benefits from memoization, it is required to use React.memo or PureComponent wrappers to prevent components from unwanted updates. It also needs very fine tuning and validation that there are no properties changing more often than they should. Any single incorrect propety might break all memoization like a house of cards:

This is a good time to recall YAGNI approach and focus memoization efforts only in a few hottest places of your app. In the remaining parts of the code it is not worth adding extra complexity with useMemo/useCallback. You could benefit from writing more simple and readable code using plain functions and apply memoization patterns later when their benefits become more obvious.

Before going the memoization path, I could also recommend you checking the article "Before You memo()", where you can find some alternatives to memoization.

Learning #5. Other React API still exist

If you have a hammer, everything looks like a nail

The introduction of hooks, made some other React patterns obsolete. For example, useContext hook appeared to be more convenient than Consumer component.

However, other React features still exist and should not be forgotten. For example, let's take this hook code:

function useFocusMove() {
  const ref = useRef();
  useEffect(() => {
    function handleKeyDown(event) {
      // actual implementation is extracted outside as shown in learning #3 above
      moveFocus(ref.current, event.keyCode);
    }
    ref.current.addEventListener("keydown", handleKeyDown);
    return () => ref.current.removeEventListener("keydown", handleKeyDown);
  }, []);
  return ref;
}

// usage
function Demo() {
  const ref = useFocusMove();
  return <ul ref={ref} />;
}
Enter fullscreen mode Exit fullscreen mode

It may look like a proper use-case for hooks, but why couldn't we delegate the actual event subscription to React instead of doing manually? Here is an alternative version:

function useFocusMove() {
  const ref = useRef();
  function handleKeyDown(event) {
    // actual implementation is extracted outside as shown in learning #3 above
    moveFocus(ref.current, event.keyCode);
  }
  return [ref, handleKeyDown];
}

// usage
function Demo() {
  const [ref, handleKeyDown] = useFocusMove();
  return <ul ref={ref} onKeyDown={handleKeyDown} />;
}
Enter fullscreen mode Exit fullscreen mode

The new hook implementation is shorter and has an advantage as hook consumers can now decide where to attach the listener, in case if they have more complex UI.

This was only one example, there could be many other scenarios, but the primary point remains the same – there are many React patterns (high-order components, render props, and others) which still exist and make sense even if hooks are available.

Conclusion

Basically, all learnings above go to one fundamental aspect: keep the code short and easy to read. You will be able to extend and refactor it later in the future. Follow the standard programming patterns and your hook-based codebase will live long and prosper.

Top comments (3)

Collapse
 
tkdodo profile image
Dominik D

Good read! Question on number 2: Would we not want to keep the function as dependency and let the user solve it, either with useCallback or by letting the user move the isInteractive function outside if the component:

const isInteractive = (option) => !option.disabled

...

const [ref, handleKeyDown] = useFocusMove({ isInteractive })
Enter fullscreen mode Exit fullscreen mode

With this, you can still closure over a prop in isInteractive if you want to

Collapse
 
justboris profile image
Boris Serdiuk

The answer depends on the use-case. Some things are not supposed to change in runtime and it is nice to move them out of the dependencies tracking logic altogether

For example, you can see this pattern in react-redux hook: react-redux.js.org/api/hooks#custo...

Collapse
 
cruxcode profile image
cruxcode

Hi Boris, nice article. I differ on few things:
Your article suggests that we should not use useMemo / useCallback in favor of code readablity. I would argue that using these hooks makes the code even more readable. We now clearly know which pieces of our code actually involves compuation.

Also, in my opinion it's not a good idea to create hooks based upon single responsibility principle. This actually makes the code hard to follow in future. A better principle would be "Every hook must return something that gets used in the UI". In other words, don't break hooks into smaller hooks until you need the intermediate outputs to be displayed in the UI.

I have asked my team to follow these principles and it helps us a lot in manual code reviews and automated code reviews. I would love to hear your thoughts on this guide - shyamswaroop.hashnode.dev/react-pa...