An opinionated guide to React hooks
Boris Serdiuk
Posted on February 10, 2022
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
}
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("");
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);
};
}, []);
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);
};
}, []);
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>
</>
);
}
This is what we see in the console after clicking the button:
"during update";
"after update";
"layout effect";
"microtask after update";
"effect";
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);
};
}, []);
This is redundant, here we just reinvented a wheel (useEffect). This code will do the same, but much simpler:
useEffect(() => {
/*do something*/
}, []);
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>;
}
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]);
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]);
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]);
Now we need to useCallback-ify this too:
const doSomething = useCallback(() => {}, []);
const handler = useCallback(() => {}, [doSomething]);
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={() => {}}}>
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;
}
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]);
}
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]);
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]);
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]);
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>
);
}
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>
);
}
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} />;
});
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} />;
});
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;
}
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" }} />;
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;
}
In React devtools we will see this value along with other components props:
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
Posted on February 10, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.