DEV Community

Boris Serdiuk
Boris Serdiuk

Posted on

An opinionated guide to React hooks

React API offers you multiple built-in hooks. However not all of them are equally useful. Some you can see almost in every app or a library, some others you will not need unless you are writing a super special module. React documentation gives some guidance where to use hooks, but in a super neutral format. In this article I will try to dive deeper into the real use-cases, giving my opinion on how every hook should be used.

Basic hooks

In their docs, React already has separation on basic and advanced hooks:

Basic

  • useState
  • useEffect
  • useContext

Advanced

  • useReducer
  • useRef
  • useLayoutEffect
  • useImperativeHandle
  • useCallback
  • useMemo
  • useDebugValue

The docs do not clarify reasons for this separation, however it is important for understanding the hooks API. Basic hooks cover some common use-cases, their purpose is clear and does not cause any controversy in the discussions.

Advanced hooks

You likely do not need to use these hooks. Almost every task can be solved without these, you will get clean and idiomatic React code. Every time you use a hook from this list, you are making a compromise and stepping off the normal "React-way". You need to have a good reason and explanation to use a hook from the advanced list. In this article we cover typical valid and invalid use-cases for advanced hooks.

useReducer

This is a form of setState for complex values. Sometimes you store not just one value, but a combination of related values. For example, state of a data-fetching process:

interface DataFetchingState {
  data: Data | null; // fetched data
  isLoading: boolean; // whether data-fetching is in progress
  error: Error | null; // error information, if data-fetching attempt failed
}
Enter fullscreen mode Exit fullscreen mode

This can be solved using a few separate useState hooks. However you may want to enforce some constraints in this state, for example prvent a combination of {isLoading: true, error: anError}. Previous error needs to be removed when a new data-fetching attempt begins. useReducer allows you to control state changes via wrapping them into actions. This way you can only dispatch a certain predefined set of actions, which will properly handle the respective state changes.

When to use it? I would recommend switching to useReducer when you have 3 or more related state values. Fewer values work just fine via useState, useReducer would be an overkill, it will require you to write more code to handle a simple case.

When not to use it? If you have multiple state values, but they all are unrelated. For example, you have multiple form fields:

const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
Enter fullscreen mode Exit fullscreen mode

These fields do not depend on each other, user can fill them in any order. Even though there are 3 different values, they are not related, so no need for useReducer.

useRef

Originally, refs in React provided a way to interact with DOM nodes directly. However, later this concept evolved into a general storage of any kind of value between component renders. useRef is also recommended as a replacement for class instance properities, this.something, which is not available in functional components.

When to use it?

If you need to access a DOM node, this hook seems unavoidable, however ask yourself first β€” do I really need to manipulate with DOM by hand? When you go this way, you become in charge of handling state updates properly and integrate with component mount/unmount lifecycle. Basically, you are stepping off one of the greatest power in React – the VDOM. Did you check if there is an option to do the same manipulation by refactoring your CSS? Or can you just read the DOM value inside an event handler via event.target and therefore reduce the number of direct manipulations down to events only?

Then we also have a use-case about storing other content, not DOM nodes. Note, that assigning ref.current = newValue does not trigger a component re-render. If you need this, perhaps it is better to put it into useState?

Sometimes you put the value in ref to later use it inside effect cleanup. However, it is redundant in some cases:

const observerRef = useRef();
useEffect(() => {
  observerRef.current = new MutationObserver(() => {
    /* do something */
  });
  observerRef.current.observe(document.body);
  return () => {
    observerRef.current.unobserve(document.body);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Using observerRef is redundant here. The value can be stored as a plain variable:

useEffect(() => {
  const observer = new MutationObserver(() => {
    /* do something */
  });
  observer.observe(document.body);
  return () => {
    observer.unobserve(document.body);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

This is also much shorter to write!

To sum it up, useRef in your components only if these conditions met:

  • The value does not depend on component rendering
  • The value cannot be stored inside a closure of useEffect hook

useLayoutEffect

This is where many people may fall into the trap "misguided by the name". If the hook name contains layout, I should put all my layout operations there, shouldn't I? However, this is not always the case. The primary difference between useEffect and useLayoutEffect is the timing of the operation. useEffect is asynchronous and useLayoutEffect is synchronous. Let's look at a simple demo:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("effect");
  }, [count]);

  useLayoutEffect(() => {
    console.log("layout effect");
  }, [count]);

  function onClick() {
    setCount((count) => {
      console.log("during update");
      return count + 1;
    });
    console.log("after update");
    Promise.resolve().then(() => {
      console.log("microtask after update");
    });
  }

  return (
    <>
      <button onClick={onClick}>increment</button>
      <div>{count}</div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is what we see in the console after clicking the button:

"during update";
"after update";
"layout effect";
"microtask after update";
"effect";
Enter fullscreen mode Exit fullscreen mode

Effect is the most delayed operation here. It gets called when all other updates completed and you can read the final DOM state (or do any other side effects). Layout effect fires right after React finished its updates, but before browser repainted the page. It is useful to apply some adjustments before user sees fully rendered page, however beware of forced sychronous layouts which may slow down the rendering performance, especially, if you call that effect often. Also, keep in mind that because layout effect is synchronous, some other operations may not be completed yet. I happened to see this code:

useLayoutEffect(() => {
  // delaying operation because something is not ready yet
  const frame = requestAnimationFrame(() => {
    /*do something*/
  });
  return () => {
    cancelAnimationFrame(frame);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

This is redundant, here we just reinvented a wheel (useEffect). This code will do the same, but much simpler:

useEffect(() => {
  /*do something*/
}, []);
Enter fullscreen mode Exit fullscreen mode

Also note if useLayoutEffect tries to execute during server-side rendering, it prints you a warning. This is also likely a sign you should be using useEffect instead.

useCallback

When we define an inline function inside our functional component, we are getting a new instance on each render

function Demo() {
  const handler = () => {};
  return <div>something</div>;
}
Enter fullscreen mode Exit fullscreen mode

Usually, it does not cause any inconvenience. However, sometimes it happens, most often when the handler is a dependency of useEffect:

const handler = () => {};

useEffect(() => {
  // heavy side effect is here
}, [handler]);
Enter fullscreen mode Exit fullscreen mode

Whenever handler changes, "heavy side effect" will be executed again. However, because handler function is inline, the change will be detected on every component render. useCallback comes to the rescue:

// now we have the same instance of `handler` on each render
const handler = useCallback(() => {}, []);

useEffect(() => {
  // heavy side effect is here
}, [handler]);
Enter fullscreen mode Exit fullscreen mode

However it only works that easy with [] in the dependencies array. More likely, there will be something, sometimes another function:

const doSomething = () => {};
const handler = useCallback(() => {}, [doSomething]);
Enter fullscreen mode Exit fullscreen mode

Now we need to useCallback-ify this too:

const doSomething = useCallback(() => {}, []);
const handler = useCallback(() => {}, [doSomething]);
Enter fullscreen mode Exit fullscreen mode

This way we are piling up a fragile pyramid of callbacks, if any of them will not memoize properly, the heavy side effect will be executed regardless our efforts. Very often it happens when we receive a value from props:

function Demo({ onChange }) {
  const handler = useCallback(() => {
      onChange();
      // do something else
  }, [onChange]);

  useEffect(() => {
    // heavy side effect is here
  }, [handler]);
}

// oh no! Our side effect got out of control!
<Demo onChange={() => {}}}>
Enter fullscreen mode Exit fullscreen mode

We might useCallback-ify the handler in the parent component too, but how do we ensure we captured all instances? The code may be split in different files and even repositories. The effort seems futile.

Fortunately, there is a more elegant solution to this problem, React documentation mentions this:

// custom reusable hook
function useStableCallback(fn) {
  const ref = useRef();
  useEffect(() => {
    ref.current = fn;
  }, [fn]);
  const stableCallback = useCallback((...args) => {
    return ref.current(...args);
  }, []);
  return stableCallback;
}
Enter fullscreen mode Exit fullscreen mode

This way we are getting back to a simple dependency-free useCallback, which relies on ref to deliver the actual latest value. Now we can refactor our code and remove all manual dependency tracking:

function Demo({ onChange }) {
  const handler = useStableCallback(() => {
    onChange();
    // do something else
  });

  useEffect(() => {
    // heavy side effect is here
  }, [handler]);
}
Enter fullscreen mode Exit fullscreen mode

Now we do not have to worry about onChange reference, handler will be called with latest instance, whichever it was at the moment of calling.

When not to use it? Do not useCallback if you have a cascade of functions depending on each other. Consider refactoring via useStableCallback custom hook. For functions in useEffect dependencies, wrap only the the direct dependency, all other functions may remain inline arrow functions, keeping your code simple and readable.

When not to use it? Do not useCallback to "optimize" event handlers. There is no evidence that it improves anything. Adding event listeners to DOM-nodes is a super cheap operation, a fraction of millisecond. On the other hand, wrapping into useCallback is also not a free operation, it comes with a cost, more expensive than actually refreshing event handlers. React is already optimized by default, no need over-optimize by hand. If you do not trust me, make your own experiments, try to find a difference and let me know, I will be happy to learn!

useMemo

This is a bigger brother of useCallback. That hook worked only for functions, this one can store any kind of values:

// avoid computing fibonacci number on every render
const fib = useMemo(() => {
  return fibonacci(N);
}, [N]);
Enter fullscreen mode Exit fullscreen mode

Sometimes you integrate with a 3rd-party library and you need to create an object instance, but this one is expensive:

const ace = useMemo(() => {
  const editor = ace.edit(editorRef.current);
  editor.on("change", onChange);
}, [onChange]);
Enter fullscreen mode Exit fullscreen mode

Note, that the hazard of dependencies from useCallback applies here too. Solution is also the same – wrap into stable callback

const onChangeStable = useStableCallback(onChange);
const ace = useMemo(() => {
  const editor = ace.edit(editorRef.current);
  editor.on("change", onChangeStable);
}, [onChangeStable]);
Enter fullscreen mode Exit fullscreen mode

When to use it? When you have a solid proof that your operation is expensive (for example, you compute fibonacci numbers, or instantiate a heavy object).

When not to use it? When you are unsure if the operation is expensive or not. For example, this is unnecessary:

function Select({ options }) {
  const mappedOptions = useMemo(
    () => options.map((option) => processOption(option)),
    [options]
  );

  return (
    <select>
      {mappedOptions.map(({ label, value }) => (
        <option value={value}>{label}</option>
      ))}
    </select>
  );
}
Enter fullscreen mode Exit fullscreen mode

Always bechmark your code before doing any optimizations! There will not be millions of items in options array (in which case we will need to talk about UX in your app). Memoization does not improve anything in render time. The code could be simplified without any harm:

function Select({ options }) {
  const mappedOptions = options.map((option) => processOption(option));

  return (
    <select>
      {mappedOptions.map(({ label, value }) => (
        <option value={value}>{label}</option>
      ))}
    </select>
  );
}
Enter fullscreen mode Exit fullscreen mode

How to useMemo properly: you write the code without any memoization, then confirm it is slow and this slowdown is significant (this is an important step, many potential optimizations will not pass this check). If there is a confirmed improvement, create also a test to ensure that the optimization worked and has an obsevable impact. Do not forget about useMemo dependencies array, any change there will waste all your efforts. Choose your dependencies carefully!

Super advanced hooks

This section could be called "wow, what is that hook?" These hooks have super niche use-cases and if you have one, you likely already know everything this article wanted to say, but here we go anyway.

useImperativeHandle

React tries to be a declarative framework, where you are describing what you want to get and then React internally figures out how. However, in real world, there are many imperative APIs, for example is focusing DOM elements programmatically.

Let's say we are building an custom Input component:

const Input = React.forwardRef((props, ref) => {
  return <input ref={ref} />;
});
Enter fullscreen mode Exit fullscreen mode

It is a good practice to wrap component into forwardRef to allow consumers to interact with the underlying native input, for example focus it via inputRef.current.focus(). However, sometimes we may want to add some extra code when the native element gets focused. useImperativeHandle helps us to proxy the call:

const Input = React.forwardRef((props, ref) => {
  const nativeInputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      nativeInputRef.current.focus();
      // do something else, if needed
    },
  }));

  return <input ref={nativeInputRef} />;
});
Enter fullscreen mode Exit fullscreen mode

Note that this way we also encapsulating access to the underlying <input> element, only focus function is exposed. This is also useful when you want to enforce API boundaries for your components and prevent unauthorized access to element internals.

useDebugValue

React recommends to extracting group of related hooks into a function and treat it as a custom hook. For example we created a custom useStableCallback hook above:

function useStableCallback(fn) {
  const ref = useRef();
  useEffect(() => {
    ref.current = fn;
  }, [fn]);
  const stableCallback = useCallback((...args) => ref.current(...args), []);
  return stableCallback;
}
Enter fullscreen mode Exit fullscreen mode

We can have multiple other custom hooks, for example useDarkMode(), which returns you the current color scheme of the page:

const darkMode = useDarkMode();

<div style={{ background: darkMode ? "darkblue" : "deepskyblue" }} />;
Enter fullscreen mode Exit fullscreen mode

How can we inspect the latest return value of useDarkMode. We can put console.log(darkMode), but the log message will be out of the context. useDebugValue connects the value with the hook it was called from:

function useDarkMode() {
  const darkMode = getDarkModeValueSomehow();
  useDebugValue(darkMode);
  return darkMode;
}
Enter fullscreen mode Exit fullscreen mode

In React devtools we will see this value along with other components props:

Image description

here is our hook in the bottom left corner

Conclusion

There is nothing else to add in the end. I hope you found this guide useful. Happy coding!

If you want to see more content from me, please check also my Twitter account: @justboriss

Top comments (0)