About two months ago, I learned that my company’s Frontend Team holds regularly scheduled working group meetings. At these meetings, the group comes together and tries to efficiently knock out a task that has been on the backburner (this week, for example, it was ESLint warnings). The working group is open to any interested party whether or not they’re a member of the Frontend Team and it’s a great way to network, contribute, and learn something new.
And so, for the past two months, I’ve been coming up with excuses not to attend the Frontend Working Group. This week, however, I finally worked up the courage to log in and it was absolutely as advertised. The group was friendly, spend an hour talking about the advantages of different React and Typescript patterns, and was grateful for someone who wanted to help. Unfortunately…I knew far too little about React and about our frontend codebase to actually contribute anything. So, as part of my commitment to network and grow, this week I’ll write about one of the subjects that was discussed: when to use useCallback in React.
React Hooks
I think it’s important to establish a few things about React before we get to this specific Hook, so if you’re a React junkie feel free to skip ahead. The magic of this framework is how intuitive it makes responsive page re-rendering. Anything we want to be dynamically rendered can be part of a component’s state and, if that state ever changes, React will re-render the page with the new data. Since useCallback uses React Hooks, we can look at a simple example to illustrate the principle of state:
import React from ‘react’;function App() {
const [count, setCount] = React.useState(0); const handleClick = useCallback(() => {
setCount(count + 1)
}, []) return(
<button onClick={handleClick}>{count}</button>
)
}export default App;
If you’d like to follow along, check out the React Docs here. Seriously, I’m using a computer where I’ve never written React and it took me about 10 minutes to download VS Code, install npx, start a project, and write this component.
The first declaration we see inside of our functional component is a destructuring of the return value of useState (also known as the State Hook or useState Hook). As I mentioned previously, React uses state to keep track of potentially changing values that we will use to render or provide functionality to our interface. The useState Hook is a function that accepts an argument of some value. It then returns an array of two elements: the value we just provided, and a function that can update that variable. What’s abstracted by this Hook are the implications that come along with React state, most notably that components that reference that state value will be re-rendered when it changes.
Our next function is handleClick, which invokes the setCount function returned by useState to increase our count variable by 1. Finally, we see that our component renders a button which displays the current count. React’s JSX elements can all be assigned an onClick attribute, which uses an event listener to invoke a callback function when the element is clicked. In this case, we’re using handleClick.
It all comes together to render a button that displays a count. The count is increased when the button is clicked and then the button itself is updated to display the new value. These are the basic tenets of React and Hooks: when state changes, our components re-render according to the new state. When our count value goes from 0 to 1, our webpage will reflect the change because it’s actually been re-rendered.
Performance Concerns
What is the impact of re-rendering a component or components? In our example, App is responsible for displaying a button with a value inside, but it’s also responsible for defining a function, handleClick. That function is always going to be the same, so we might decide that it’s a waste of energy to redefine it each time we render our component. If this is a concern for us, we could use the useCallback Hook to provide a condition for redefinition (you may have also seen this done in certain situations with the useEffect Hook).
Whether it’s useCallback or useEffect, according to my reading, this pattern is overused based on the misconception that it promotes efficiency. Before we get into why that is, let’s look at how these Hooks might be used:
const handleClick = useCallback(() => {
setCount(count + 1)
}, [])
The Hook accepts two arguments: a callback function and an array of dependencies. The function is memoized when it is first defined, meaning it is saved in memory for future reference. The next time our component is rendered, that version of the function will be retrieved unless the values of one of the dependencies has changed. It might be easier to understand when looking at the example from the React Docs:
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
The main difference here is that our callback function makes use of a set of arguments, which are in turn declared as dependencies. If a or b changes, a new function will be defined. If not, we’ll retrieve the original one from memory.
So what’s the issue? We’re potentially saving ourselves time by pulling an existing function from memory instead of having to redefine it. If that function is the parent of other React components, we’d be saving a lot of unnecessary computations. While this is true, we should also consider:
- React is already fast and efficient. It’s built to consistently re-render components and doing so might not put as much stress on our app as we’d expect.
- These functions actually have to be defined anyway, even if we don’t end up using them. useCallback, useEffect, and useMemo don’t prevent that from happening, so this will actually be taking up more memory than using the original version of the function.
- This is harder to read. A minor optimization might not be worth the additional complexity.
So what’s the point of useCallback?
The articles that I read about useCallback were mainly aimed at programmers who use the Hook out of habit because they view it as a generally good practice. Naturally, it exists for a reason, so don’t feel like it should be completely discarded. Consider these two facts:
- Hooks like useEffect and useCallback trigger callbacks based on whether dependencies change.
- useCallback can reference a previously defined callback rather than a newly created one.
What does this mean for us? Let’s make up an example:
const Button = (var1, var2) => {
useEffect(
() => {
unwrittenCallback(var1, var2)
},
[var1, var2]
) return <button>Push me</button>
}
In the example, we define a Button component that:
- accepts two arguments, var1 and var2
- invokes useEffect
- renders a button that says “Push me”
In case you’re not familiar, the useEffect Hook runs a callback function after React has updated our page’s DOM. It’s generally used to create “side effects” (or effects, hence the name), meaning something useful to our interface though not necessarily required for the initial render. In any case, we can see that the callback here is a function called unwrittenCallback that uses the two variables passed into the Button component. It doesn’t matter how the callback uses these variables, which is why I didn’t bother to write that out. What’s important is that the variables are listed as dependencies. That means that this code will only be invoked if one of those values change.
And that’s the end of the story if the values we’re passing down are primitive data types, like integers or strings. But what if they’re not? If you’re familiar with JavaScript gotchas, you might have seen something like this before:
const funcA = () => {}const funcB = () => {}funcA === funcB // => false
Though the functions are defined identically, they are not seen as equivalent by JavaScript. They are each a unique object and therefore, if we redefine them each time we invoke Button, the callback inside of useEffect will rerun even though we probably don’t want it to. That is unless…
const Button = (var1, var2) => {
useEffect(
() => {
unwrittenCallback(var1, var2)
},
[var1, var2]
) return <button>Push me</button>
}const ButtonParent = () => {
const var1 = useCallback(() => {}, [])
const var2 = useCallback(() => [1, 2, 3], [])
return <Button var1={var1} var2={var2} />
}
Aha! The Hook makes defining our callbacks a bit more complicated, but it saves us from rerunning the useEffect callback in the child component because we are actually retrieving the original functions that were previously memoized. Depending on the complexity or the impact of the useEffect callback, this could make a big difference for us.
Thoughtful Decisions
The articles I read to research this question are very well written and provide additional examples of when these Hooks are appropriate — I highly recommend checking them out in the Sources below. I came away from both with one big lesson: be thoughtful about code optimization and avoid additional complexity unless you can prove that it has improved efficiency by running tests. And outside of the articles, I was once again reminded that our fellow engineers are often a friendly bunch who want to encourage learning and share their knowledge. I’m looking forward to my opportunity to do the same in the workplace.
Sources:
- When to useMemo and useCallback by Kent C. Dodds
- Your Guide to React.useCallback() by Dmitri Pavlutin
- React Docs: Getting Started
- React Docs: Hooks