Analyzing the hooks version
We converted the TODOs app from using class and made it use functions and hooks. This simple conversion comes with a small penalty. All functions that were previously part of a component "instance" will now be recreated on each and every render of a function component. The instance in class-based components preserves the identity for these functions.
Similarly, the PureComponent
class is another form of caching/memoizing a section of a class-based React app.
Optimizing the hooks version to match the class version is the tricky part of this conversion. The way functions and hooks work is vastly different from how classes and instances work. There is no easy way to compare the 2 versions performance without measuring. However, there are a couple of things we can do right away. We can use React.memo
to replace the PureComponent
concept and we can use React.useCallback
to preserve the identity of functions that need to be passed down to children props.
Pure function component with React.memo
To make a React function component a pure one, you just wrap it with a React.memo
call:
// Class version class TodosLeft extends React.PureComponent { // ... } // Hooks version const TodosLeft = React.memo(function TodosLleft ({ todos }){ // ... });
The React.memo
function takes a function component as an argument and returns its result after memoizing it (for the used input props). When it’s time to re-render this component, if the component props are exactly the same (using a shallow compare) React will skip rendering the component and just reuse the last rendered result.
Note how I used JavaScript regular function syntax for the TodosLeft function instead of the arrow function I used before. This is just to give the memoized component a name (while defining it inline). If you pass an anonymous arrow function to React.memo it’ll report the component as just "Memo" in React’s dev tools. Using a named regular function it’ll report the component as Memo(TodosLeft) , which is much better (in development).
|
We can use a React.memo
wrapping call for all 4 child components that used to extend React.PureComponent
in the class version. I’ve done this in hooks version used below.
Preserving function identity with React.useCallback
When React renders a component, all functions defined within that component will get a different identity (place in memory). The old functions will be removed and new ones will be defined.
For example, on each render of the AddTodoForm
component, a new handleSubmit
function will be created. This was not the case in the class version where we stored handleSubmit
on the instance of AddTodoForm
. The class version of AddTodoForm
will create the handleSubmit
function on the first render and then re-use the same function on each re-render.
In most cases, this is not a big deal. A JavaScript engine like V8 will take care of creating and discarding functions and the overhead of creating new functions is minimal. However, when the functions being created are passed down to pure children component they will cause the pure component to re-render even when nothing has changed in its props. Just the fact that we have a new function will invalidate the pure component memoization.
To see this problem in action, test the app interactions in both versions below. The first one is the class version, and the second one is the hooks version so far after memoizing all pure components. Both of them will keep track of how many times the child components are rendered.
Just mark a TODO as done in both versions and check the render counts:
The class version:
The hooks version:
Marking a TODO as done in the class version caused 2 components to re-render. The TodoItem
component that changed the UI for the newly done TODO and the TodosLeft
component to update the active TODOs count. This makes sense.
Marking a TODO as done in the hooks version caused 5 components to re-render! All TodoItem
elements used were re-rendered in addition to AddTodoForm
and TodosLeft
. This is what’s known in React as "wasteful renders". 3 of these 5 re-renders were not necessary. Let’s focus on the AddTodoForm
. Why exactly was it re-rendered when me marked a TODO as done? Didn’t we memoize the thing!?
The reason is, when the TodosApp
component re-renders when we changed the state of one TODO, the addNewTodo
function was re-created. It now has a new identity that’s different from the one used in the first render. This means the onSubmit
prop for the AddTodoForm
is different now (because it uses a new addNewTodo version) so React will not use the memoized version of AddTodoForm
. This can be avoided because the newly created addNewTodo
function is exactly the same as the old one. It does need to be "refreshed" on each render. It does not depend on any local variables. The only variable it depends on is the setTodos
updater function and that function is guaranteed to be the exact same value for all renders.
This can be solved by preserving the identity of the first created addNewTodo
function using the React.useCallback
hook function. Also, If you need to preserve the identity of a computed scalar value (like an object or an array), you can use the React.useMemo
hook function.
These 2 hooks are similar in signature but they have different purposes. They take the same 2 arguments:
-
The first argument is a function. In
useMemo
that function is usually referred to as the "create" function while withuseCallback
it’s usually referred to as the "callback" function. -
The second argument is an array of
dependencies
(sometimes referred to asinputs
and it’s similar to the one we’ve seen for the useEffect hook function).
useCallback(function callback() { }, [dep1, dep2, ...]); useMemo(function create() { }, [dep1, dep2, ...]);
The first time React invokes these functions (within a component’s render cycle), they return different things:
-
useCallback
returns itscallback
function. It does not invoke it. -
useMemo
invokes itscreate
function and returns whatever thatcreate
function returns.
So if you have the following:
const C = useCallback(() => [1, 2, 3], []); const M = useMemo(() => [1, 2, 3], []);
What is the value of C
and M
?
-
C
is a function that returns[1,2,3]
-
M
is[1,2,3]
useMemo(() => fn, deps) as being equivalent to: useCallback(fn, deps) |
Further invocations to the useCallback
and useMemo
functions in subsequent renders will only make these functions "redo their thing" if and only if a value in their array of dependencies has changed. So if all the values in the array of dependencies are exactly the same (or if the array of dependencies was empty) then all subsequent invocations to a useCallback
function will return the exact same reference to the callback function that was defined on the first render, and all subsequent invocations to a useMemo
function will return the exact same reference to the object that was created on the first render.
The useMemo
hook is useful when you have expensive computations that you want React to skip doing when you know their result will be exactly the same. Both useMemo
and useCallback
are useful when you need to include functions or computed values as dependencies for other hooks (like useEffect
). They are also often used with React.memo
to avoid wasteful renders, which is what we need for this example.
You can wrap any function defined in a React component with a useCallback
call and React will preserve its identity in the next render if its list of dependencies are exactly the same. Note that both useCallback
and useMemo
functions HAVE to be used with a list of dependencies as otherwise they are useless. If what you’re trying to memoize has no dependencies at all, you need to pass an empty array as the list of dependencies. This is the case for the addNewTodo
function. It does not have any dependencies. Here’s how we memoize it:
// In the TodosApp function component: const addNewTodo = useCallback( newTodoBody => setTodos(prevTodos => ({ ...prevTodos, [uniqueId()]: { body: newTodoBody, done: false, }, })), [] );
With that change, the onSubmit
prop for AddTodoForm
will be exactly the same when TodosApp
re-renders and because AddTodoForm
function is itself memoized, React will skip re-rendering it.
Is this better though?
Maybe… The useCallback
and useMemo
hooks come with some penalties. First, they make the code harder to read but you can probably get over that fairly quickly. However, keep in mind that they will make your code do a few extra things. They’ll make it allocate arrays in memory, do a new function call that loops over an array of values and compare things, and hold on to references in memory (blocking things from being garbage collected).
You should only use these optimization hooks if you know that what you’re trying to optimize is far worse than all of the penalties they will introduce. The only way to know for sure whether introducing them is better or worse is to measure things before and after using them.
For example, it’s probably not a good idea to memoize the handleSubmit
function inside AddTodoForm
as it’ll just add extra work for React. This function is not passed down to any child components and therefore not responsible for any wasteful renders. That’s generally a good rule of thumb. If a function is passed as a prop to a memoized component then preserving its identity might be good especially if the memoized component makes any expensive computations.
The functions that are passed as props to memoized components in the hooks version are addNewTodo
, markTodoDone
, deleteTodo
, and deleteAllCompleteTodos
. These should probably be memoized but all the other functions like shouldShowTodo
and the event handlers should not be memoized at all.
However, since all 4 functions to be memoized share the fact that they don’t depend on any local variables, instead of using 4 useCallback functions (and dealing with the penalties 4 times per render) we can simply group them under an "actions" object and memoize that object with useMemo
(and get the penalties just ONE time per render).
const { addNewTodo, markTodoDone, deleteTodo, deleteAllCompleteTodos, } = useMemo( () => ({ addNewTodo: newTodoBody => setTodos(prevTodos => ({ ...prevTodos, [uniqueId()]: { body: newTodoBody, done: false, }, })), markTodoDone: (todoId, newDoneValue) => setTodos(prevTodos => ({ ...prevTodos, [todoId]: { ...prevTodos[todoId], done: newDoneValue, }, })), deleteTodo: todoId => setTodos(prevTodos => { const { [todoId]: _, ...todos } = prevTodos; return todos; }), deleteAllCompleteTodos: () => setTodos(prevTodos => Object.entries(prevTodos).reduce((acc, [todoId, todo]) => { if (!todo.done) { acc[todoId] = todo; } return acc; }, {}) ), }), [] );
Not only is this better in terms of the number of times your code has to deal with the memoization complexities but it also just makes sense to have these actions grouped together. They operate on the same object. In fact, this move should hint that maybe this actions
object along with a list of todos
should be extracted into some sore of "list manager". We can use the same actions object with other lists that has a structure similar to the TODOs list.
Checkout this article where I do exactly that using a React "custom hook":
Here’s the final version of the hooks way after memoizing the 4 state mutating action functions. Compare the version below with the class version above and see how the number of render React is now doing is exactly the same.
But remember, even after all these optimizations for the hooks version, we cannot and should not tell which version is performing better without measuring. For example, use Chrome DevTools performance tab to record both CPU and memory usage in both versions while doing exactly the same steps in the app and compare memory usage, time spent scripting, time spent rendering, and the many other measures this tool report. Only then you can make an educated decision on which version is better.
I did a few quick measurements and the hooks version slightly out-performed the class one ;)