React Hooks Deep Dive
In this chapter, I’ll start things off with a simple TODOs app built (and a bit optimized) with React classes. I’ll explain the core concepts used in that version.
I will then convert the app to use React function components with hooks instead of classes following a simple syntax conversion strategy.
After that, I’ll tell you why simple syntax conversion is not enough and how some optimizations in the class version might stop working and how to fix that.
Why A TODOs App?
Because it’s a realistic non-abstract example with a decent set of features that’s not too small or too big. It’s really the new "Hello World" example when it comes to a web UI.
Chances are you’re familiar with the features that are usually presented in it but here’s a recap:
-
Show a list of TODOs
-
Have a checkbox to check a TODO as complete
-
Have a way for the user to delete a TODO and a way to delete all completed TODOs
-
Render a simple form with an input box and a button to add a new TODO to the list
-
Show the count of active TODOs in the UI and as the title of the app
-
Show filtering buttons to make the UI show all TODOs, just the active ones, or just the completed ones
I’ve also added a few constraints to the app:
-
Use an object to manage the list of TODOs (instead of an array for example)
-
Support initial data (to server-render TODOs for example)
-
Optimize component render calls (what React does in memory) and only have React renders what needs to be rendered (when props/state changes)
Please note that the conversion we’re going to do in this guide covers only the common patterns in React like state, props, and mount/update lifecycle methods. It does not cover any "advanced" patterns like context, higher order components, children as a function, etc. There is also no "data fetching" in this conversion. |
The class version
Here’s the code and preview for the class version. Give the UI a spin (it’s all interactive) and scan through the code and familiarize yourself with its main parts but don’t worry about the details just yet. I’ll explain the main concepts used here shortly.
Please note that I tried to keep the code simple but realistically close to what you’ll see in actual React apps out there. For example, I used some modern JS features like class fields and arrow functions and avoided manual binding of handlers.
Designing the components tree
When building React Apps, the first step is to think about the components tree. Here are the 5 components I picked for this example:
TodosApp // Rendered once, top-level TodoItem // Rendered for each TODO AddTodoForm // Rendered once FilterButton // Rendered 3 times for filtering TodosLeft // Rendered once
The components I picked for this example are minimal. You can certainly add more components if you want to. For example, you can group the list of TODOs under a TodosList
component or group all action buttons under an ActionButtons
component. You might think of adding a Layout
component to control the layout of the main elements in the App.
Adding more components for readability is often good but keep in mind that adding components that depend on the App state requires having these components receive that state somehow. This often means more complexity and a negative effect on readability.
I often start with the minimal components I can think of and extract new ones as I see the need for them. I think it’s totally okay to start with even a single component and add more only when you have good reasons to do so. However, let me give you some clues to help you come up with an initial design for your components tree.
Planning for components
There are many reasons to make a part of your app into its own component. Many of these reasons fall generally under the readability and reusability categories but some of them are also about maintainability.
A popular reason to introduce a component is to avoid duplication of sub-trees in the main tree or to make parts in the tree reusable in general. Another popular reason is to split big component into smaller ones. However, these are the clues you can use to decide whether or not to extract components from other components.
What clues can you use to plan for the initial shape of your components tree?
Repetition and behavior
One good clue to look for when designing your minimal components is whether an element is going to be repeated (with a loop for example) and especially if the repeated elements are going to have some shared UI behavior. In the simple TODOs example, the TODO items are repeated and they share the UI behavior to flag a TODO as complete or delete it. The filter buttons are also repeated and they share the behavior to modify what TODOs are displayed in the list. These are good clues to decide on a TodoItem
component and a FilterButton
component.
Responsibility isolation
Another clue to look for is whether a certain part of the code is logically grouped with a certain responsibility. In this App, the part where the user needs to type in a new TODO item and submit it to the list is a good example of this. This logically-grouped responsibility is a good candidate to have as a component (which I named AddTodoForm
).
Optimizing renders and computations
Sometimes we introduce a component to optimize the number of times a section of the code is run as a form of "caching" (AKA memoizing) it. You can make a component extend React.PureComponent
instead of React.Component
to make React automatically skip rendering that component if its props and state elements have not changed during a re-render of the parent containing component. If you design your pure component to only depend on the absolute minimum data/state elements that it needs to perform computations, then React’s not re-rendering it would be a form of a one-time cache. That’s the exact thing I did in TodosLeft
to cache the computation of the number of active TODOs left.
When we convert the app to use function components instead of class components we can use the React.memo
function to have a behavior similar to PureComponent
but we’ll need to do a few extra things to exactly match the caching you’re going to see in the class version.
If you use React.PureComponent (or React.memo ) it is extremely important that you do not directly modify (mutate) any arrays or objects which are used by these components. You’ll run into surprising bugs if you mutate them directly.
|
I’ve actually used React.PureComponent
for all components except the root one. Using PureComponent
on the top-level root component is often not necessary especially if the app state is managed on that level. However, the children of a stateful component like TodosApp
(which will re-render frequently) can benefit from being pure. For example, if the state of one TODO item changes from active to complete then the only 2 child components that need to be re-rendered are the TodoItem
component and the TodosLeft
component. We don’t need to re-render the FilterButton
or AddTodoForm
components. If the filter state has changed from "show all" to "show only active" then only the FilterButton
component needs to be re-rendered (to mark the button as active) and nothing else in the app needs to be re-rendered (other than the top-level root component that’s rendering anyhow). We don’t even need to re-render the TodoItem
component in that case (but we do need the root component to unmount some of them). In the next lesson, after explaining the code of the class version, I’ll tell you how you can verify all of this "purity".
Analyzing the class version
Let’s take a look at the class version and understand the concepts used in it. I’ll start with the generic parts of the code that are not about the core app logic and will then explain the main components with inline code comments.
Telling React where to start
The top-level root component for this app is TodosApp
. This is what we hand to ReactDOM
to make it mount the UI it represents in the browser’s DOM. It’s the starting point of the app.
This root component receives an initialData
prop:
// The beginning of the story. Show something in the browser. ReactDOM.render( <TodosApp initialData={initialData} />, document.getElementById('mountNode') );
The ReactDOM.render
method takes 2 arguments:
-
The first argument is the "WHAT". It’s the top-level React element to be rendered to the browser. It’s an instance of
TodosApp
for this example. -
The second argument is the "WHERE". It needs to be a reference to a DOM element that’s already rendered in the browser. React will take over that target DOM element and render the source React element (the first argument) inside it.
Using realistic test data
I made the TodosApp
component receive an initialData
prop because the requirement specified that the TODO app has to support rendering an initial list of TODOs. To make things a bit more interesting and closer to reality, I made the initialData
element an object of todos
indexed by their IDs (instead of using arrays).
Here is a mock variable to represent that initial data object:
// Seed test data const initialData = { todos: { A: { body: 'Learn React Fundamentals', done: true, }, B: { body: 'Build a TODOs App', done: false, }, C: { body: 'Build a Game', done: false, }, }, };
Each TODO item needs a unique id
property, a body
text property, and a done
Boolean property to indicate its complete status.
Don’t use short or random data to test your app. Try to use realistic data. That will help you design your UIs better.
I like to use string values for IDs instead of numeric values. JavaScript is not the best language when it comes to working with numbers. |
Bringing external modules
Chances are your React app will need external functions that are not part of any component but rather imported for the app to use. Together they form what we call the app dependencies. This TODOs example app has one utility function that is external to it: uniqueId
.
Since we will be adding new TODOs, we need a way to generate unique id properties for them. This is not something that the user will provide. We can use a simple counter but that’s boring. Here’s a function that uses the current time concatenated with a random value to generate a unique id:
const uniqueId = () => Date.now().toString(36) + Math.random().toString(36);
You can also import and use an npm package for the "unique-id" dependency. For example, shortid. |
Whenever we need to add a new TODO in the UI we can use this uniqueId
function to generate a unique id
property for it. Note that this is usually something that a "todo-save" API endpoint would provide. However, for this example, new TODOs will only be saved locally in the application memory to keep things simple.
The core concepts in the class version
Let’s now take a detailed look at the code for the class version and understand the main concepts in it.
I’ll use inline comments to explain concepts after they’re used in the code. If you want to experiment with the code, the interactive version is included at the end of this lesson:
class TodosApp extends React.Component { /* The component class definition is the blueprint used every time an "element" from this component is to be rendered in the browser. React instantiates an object from this class when we do: <TodosApp ... /> and that object gets displayed in the browser. React associates this instance with the DOM-rendered element. This instance can be accessed using the "this" keyword within the class definition here. */ state = { todos: this.props.initialData.todos, filterLabel: 'All', }; /* Initialize the special state property on the instantiated object This is a "class field" syntax that's equivalent to doing "this.state = { .... }" in the class constructor method. A class component state is a plain-old JS object. This one has a "todos" property that gets its initial value from the props input and a "filterLabel" string. You can have as many properties on the state object as you need but you should keep what you put on the state to the absolute minimum. */ /* As you'll see in the many methods below, class fields can also be used with arrow functions and because of how closures work for arrow functions, we can safely use the "this" keyword within them and we'll be correctly using the instantiated instance of TodosApp. */ addNewTodo = newTodoBody => this.setState(prevState => ({ todos: { ...prevState.todos, [uniqueId()]: { body: newTodoBody, done: false, }, }, })); markTodoDone = (todoId, newDoneValue) => this.setState(prevState => ({ todos: { ...prevState.todos, [todoId]: { ...prevState.todos[todoId], done: newDoneValue, }, }, })); deleteTodo = todoId => this.setState(prevState => { const { [todoId]: _, ...todos } = prevState.todos; return { todos }; }); deleteAllCompleteTodos = () => this.setState(prevState => ({ todos: Object.entries(prevState.todos).reduce((acc, [todoId, todo)] => { if (!todo.done) { acc[todoId] = todo; } return acc; }, {}), })); setFilter = newFilterLabel => this.setState({ filterLabel: newFilterLabel }); /* The 5 methods above are mutations to be done on React's state for this component (they have nothing to do with the DOM). Also, except for the "setState" method, all the logic here is not really React but rather pure JavaScript. setState is React’s way to keep track of the state changes since React needs to “react” to all of these changes. The whole TodosApp component will get re-rendered along with all of its children every time any of these methods is called. However, React will still only take to the browser the parts that get different content based on the new modifications to the state. React uses a special "reconciliation algorithms" to do that. */ shouldShowTodo = todo => { const { filterLabel } = this.state; return ( filterLabel === 'All' || (filterLabel === 'Active' && !todo.done) || (filterLabel === 'Completed' && todo.done) ); }; /* The shouldShowTodo method perform "computations". Computing values is a common thing to do before the render method. Computation methods can be called directly in the render method below. They are NOT "handlers" like the mutation methods above. */ render() { return ( <> <header>TODO List</header> <ul> {Object.entries(this.state.todos).map( ([todoId, todo]) => this.shouldShowTodo(todo) && ( <TodoItem key={todoId} id={todoId} todo={todo} markTodoDone={this.markTodoDone} deleteTodo={this.deleteTodo} /> ) )} {/* We take the entries of the todos object and map every entry to a <TodoItem /> element passing it the props it needs. An entry is a key/value pair. The key is the id of a todo and the value is the todo object itself (body and done). Before rendering a TodoItem element, we invoke a function to check if that element should be shown in the UI based on the current filtering state. This use of the "&&" operator is called "short-circuit evaluation". If the shouldShowTodo function call is false, React will render nothing. If it's true, React will render the second part of the Boolean check, which is the TodoItem element itself. The "key" attribute is a React internal thing. It helps React identify dynamic children and what to do when they change. Your code can't depend on it. An "id" prop was defined to pass the same value used as "key". */} </ul> <AddTodoForm onSubmit={this.addNewTodo} /> <div className="actions"> Show:{' '} <FilterButton label="All" onClick={this.setFilter} active={this.state.filterLabel === 'All'} /> <FilterButton label="Active" onClick={this.setFilter} active={this.state.filterLabel === 'Active'} /> <FilterButton label="Completed" onClick={this.setFilter} active={this.state.filterLabel === 'Completed'} /> </div> <div className="actions"> <button onClick={this.deleteAllCompleteTodos}> Delete All Completed </button> </div> <footer> <TodosLeft todos={this.state.todos} /> </footer> </> ); } /* The render method is what React uses to determine the shape this element should take in the real browser's UI. It returns JSX which gets compiled into React API calls. The <></> syntax is sugar for "React.Fragment" which is a way to group React elements without introducing an unnecessary "wrapping" parent element. */ } class TodoItem extends React.PureComponent { /* We're not managing any state or side effects for a TodoItem. Each TodoItem gets purely rendered based on its props input (which contains both data and behavior). This purity makes the component a candidate to extend React.PureComponent instead of React.Component. This signals to React that if the parent component re-renders and nothing has changed in the props of a rendered TodoItem then don't bother re-rendering that TodoItem (in memory). It's pure! React can re-use the previously rendered version. */ handleCheckboxChange = event => { const newDone = event.target.checked; /* React gives access to the native DOM "event" associated with any handler. You can use event.target to access the DOM element on which the event occurred. This target is used here to read the checkbox's "checked" state */ this.props.markTodoDone(this.props.id, newDone); }; handleXClick = () => this.props.deleteTodo(this.props.id); /* The 2 local handlers above just invoke the behaviors that are pre-defined by the parent. The TodoItem component has no control over these behaviors except when to invoke them and what arguments they receive.*/ render() { const { todo } = this.props; const todoStyle = { textDecoration: todo.done ? 'line-through' : 'none' }; /* This is a computed style object. This is a common way to represent "conditional" styles. It's used below with the todo.body span */ return ( <li> <input type="checkbox" checked={todo.done} onChange={this.handleCheckboxChange} /> <span style={todoStyle}>{todo.body}</span> <span role="link" onClick={this.handleXClick}> X </span> </li> ); } } class AddTodoForm extends React.PureComponent { /* The AddTodoForm component depends on a single prop, which is the onSubmit behavior and it invokes it when the Add TODO button is clicked. It passes what the user types in an input box using the DOM directly to read that typed value. */ handleSubmit = event => { event.preventDefault(); this.props.onSubmit(event.target.todoBody.value); event.target.todoBody.value = ''; }; /* This is another example where the handled event's target attribute is used to read a user interaction. React is not aware of the UI state changes when the user types or check a checkbox. It just asks the DOM API for these value when it needs them. This is the "uncontrolled input" pattern. */ render() { return ( <form onSubmit={this.handleSubmit}> <input type="text" name="todoBody" placeholder="What TODO?" /> <button type="submit">Add TODO</button> </form> ); } } class FilterButton extends React.PureComponent { handleClick = () => this.props.onClick(this.props.label); render() { const buttonStyle = { fontWeight: this.props.active ? 'bold' : 'normal', }; /* Another example of conditional styling */ return ( <button onClick={this.handleClick} style={buttonStyle}> {this.props.label} </button> ); } } class TodosLeft extends React.PureComponent { activeTodosCount = () => Object.values(this.props.todos).filter(todo => !todo.done).length; /* This is a practical reason to have this component as "pure". When the "filterLabel" state changes in the parent TodosApp component (but the todos array is exactly the same). We do NOT need to recompute the active TODOs count. It did not change. The purity of this component will make React skip that computation for that case. */ componentDidMount() { document.title =Active TODOs: ${this.activeTodosCount()}
; } componentDidUpdate() { document.title =Active TODOs: ${this.activeTodosCount()}
; } /* The 2 lifecycle methods present an example of a "side effect". Whenever this component is re-rendered in memory it'll update the main document title to show the current active TODOs count. */ render() { return <div>TODOs left: {this.activeTodosCount()}</div>; } }
Converting from classes to hooks
We have a TODOs App written entirely with React classes. Let’s convert it to use React function components and manage the state and side effects with React hooks instead.
Step #1: Move class methods to the render function
Instead of converting a class into a function in one swoop, I find it helpful to first extract all the methods defined on the class and convert them into regular functions within the render
method. All occurrences of this.methodName
becomes methodName
in the body of the render
method. This move would include any code that’s defined in the constructor function for the component.
The only exceptions to this move are the state initializing line (this.state= ) and any lifecycle methods like componentDidMount . React hooks use a different approach to managing state and lifecycle methods need special conversions because they are called internally by React. Do these last.
|
For example, to convert the FilterButton
class in this App, here’s what it would look like after this first step:
class FilterButton extends React.Component { render() { const handleClick = () => this.props.onClick(this.props.label); const buttonStyle = { fontWeight: this.props.active ? 'bold' : 'normal', }; return ( <button onClick={handleClick} style={buttonStyle}> {this.props.labellabel} </button> ); } }
I just moved the handleClick
method to the render
method’s scope and used it directly (without this
). The new local handleClick
function WILL be re-created each time this FilterButton
component re-renders.
If this makes you uncomfortable you are not alone ;) One of the common arguments against hooks is how it requires the creation and re-creation of many functions. You need to make peace with that. It’s not really a big deal. However, there are ways to mirror the class-based approach in function components if you need to do that.
Step #2: Destructure props/state
After you move what can be moved make a note of everything in the render
method (and all its new local functions) that makes a call to this.props
or this.state
.
Introduce 2 new lines as the first thing in the render
method, one line to destructure all used props from this.props
and another for this.state
. Then change anything that uses this.props
or this.state
to use the destructured values directly instead.
In the FilterButton
class, only this.props
is used in render
(it’s a stateless component). There are 3 props used in this component: label
, active
, and onClick
. Introduce a new line to destructure them and get rid of everything this.props
inside render
:
class FilterButton extends React.Component { render() { const { label, active, onClick } = this.props; const handleClick = () => onClick(label); const buttonStyle = { fontWeight: active ? 'bold' : 'normal', }; return ( <button onClick={handleClick} style={buttonStyle}> {label} </button> ); } }
The only this.X
calls that should now exist in the render
method are a single this.props
(the new one in the first line) and a single this.state
if the component has state elements (and there will also be calls to this.setState
in handler functions).
Having this intermediate version of the converted component is handy, especially in bigger and more complex components. It’ll help you uncover issues you need to deal with early in the conversion process. You also get to test some things incrementally instead of one final test at the end.
Step #3: Convert render to a function
After the first 2 steps, simple components like FilterButton
(which has no state or side effects) have just one more step to go through. The code in the render
method becomes the code for the new function component. We just need to move the destructuring line into destructuring within the new function component’s argument:
const FilterButton = ({ label, active, onClick }) => { const handleClick = () => onClick(label); const buttonStyle = { fontWeight: active ? 'bold' : 'normal', }; return ( <button onClick={handleClick} style={buttonStyle}> {label} </button> ); };
Did you notice how the body of the FilterButton
function in V3 matches the body of the render
method in V2? Only the destructuring line was moved.
At this point, the only this.X
calls you have in your components should be this.state
and this.setState
. This is where you need to introduce a hook.
The only stateful component in this app is the root one (TodosApp
). Let’s convert that next.
Step #4: Convert state values and updaters
If a class component uses state elements, then you’ll need to replace each property on that state to use the new useState
hook function. Before we do that for TodosApp
, let’s take it through the first 3 simple steps. Here is what TodosApp
would look like after them:
const TodosApp = ({ initialData }) => { // state = { // todos: this.props.initialData.todos, // filterLabel: 'All', // }; let todos, filterLabel, setState; /* These are temp variables so that we can get rid of all "this." in the code. They need to be replaced with React.useState calls. */ const addNewTodo = newTodoBody => // setState(prevState => ({ // todos: { // ...prevState.todos, // [uniqueId()]: { // body: newTodoBody, // done: false, // }, // }, // })); // ... 4 more functions ... /* Every setState call will need to be replaced above but everything below this point can stay exactly the same */ const shouldShowTodo = todo => { return ( filterLabel === 'All' || (filterLabel === 'Active' && !todo.done) || (filterLabel === 'Completed' && todo.done) ); }; return ( <> <header>TODO List</header> <ul> {Object.entries(todos).map( ([todoId, todo]) => shouldShowTodo(todo) && ( <TodoItem key={todoId} id={todoId} todo={todo} markTodoDone={markTodoDone} deleteTodo={deleteTodo} /> ) )} </ul> <AddTodoForm onSubmit={addNewTodo} /> <div className="actions"> Show:{' '} <FilterButton label="All" onClick={setFilter} active={filterLabel === 'All'} /> <FilterButton label="Active" onClick={setFilter} active={filterLabel === 'Active'} /> <FilterButton label="Completed" onClick={setFilter} active={filterLabel === 'Completed'} /> </div> <div className="actions"> <button onClick={deleteAllCompleteTodos}> Delete All Completed </button> </div> <footer> <TodosLeft todos={todos} /> </footer> </> ); };
The TodosApp
component uses 2 properties on its state: todos
and filterLabel
. We’ll need to use either a single useState
call that manages both of them (as an object) or two useState
calls for each.
To make that decision, look at the patterns where setState
is used with these state elements. Are they ever updated together? If so, then there might be a value in grouping them under one object.
In the 5 functions we have that update the state, 4 of them update only the todos
part and 1 of them updates only the filterLabel
part. I think it’s safe to have them as separate state elements in the new hook version. This is the hooks-equivalent to initializing and reading the state:
// Class version // 1) Initialize state = { todos: this.props.initialData.todos, filterLabel: 'All', }; // 2) Get values and updater function const { todos, filterLabel } = this.state; const { setState } = this; // Hooks version // Initialize and get values and updater functions const [todos, setTodos] = useState(initialData.todos); const [filterLabel, setFilterLabel] = useState('All');
Each call to useState returns an array with 2 items:
-
The first is the current value of the used state
-
The second is an updater function to change the value of the used state
The argument to useState
is the initial value assigned to the requested state.
React uses the "order" of the |
The grouping of many state elements to be updated together is better done with |
After that, we replace each setState
call that updates the todos
property with setTodos
passing the new value of todos
for it directly:
// Class version addNewTodo = newTodoBody => this.setState(prevState => ({ todos: { ...prevState.todos, [uniqueId()]: { body: newTodoBody, done: false, }, }, })); // Hooks version const addNewTodo = newTodoBody => setTodos(prevTodos => ({ ...prevTodos, [uniqueId()]: { body: newTodoBody, done: false, }, }));
Note how in the hooks version the "previous state" argument for the setTodos
callback argument is the prevTodos
object itself. It’s a function that updates only a todos
object state (and not a general object state that has a todos
property).
Similarly, for the setFilter
function we replace setState
with a call to setFilterLabel
:
// Class version setFilter = newFilterLabel => this.setState({ filterLabel: newFilterLabel }); // Hooks version const setFilter = newFilterLabel => setFilterLabel(newFilterLabel);
The hooks version setFilter
function can actually be eliminated because it just calls another function with its exact same argument. We can use the setFilterLabel
directly as the handler for onClick
properties on the 3 FilterButton
elements:
<FilterButton label="All" onClick={setFilterLabel} active={filterLabel === 'All'} /> {/* ... 2 more FilterButton ... */}
That’s it. Since this component did not use any lifecycle methods, it’s now ready. See the full version below.
Step #5: Convert lifecycle methods
The TodosLeft
component makes use of both the componentDidMount
and componentDidUpdate
methods to keep the app title in sync each time the number of active TODOs left is re-computed.
To do that with a function component, we need to use the React.useEffect
hook. This hook function takes 2 arguments: a callback function and an array of dependencies.
useEffect(() => { // Do something after each render // but only if dep1 or dep2 changed }, [dep1, dep2]);
Here’s how the useEffect
callback is handled:
-
The first time React renders a component that has a
useEffect
call it’ll invoke its callback. -
After each new render of that component if the values of the dependencies are different from what they used to be in the previous render React will invoke the callback function again.
Furthermore, if the component is re-rendered or removed from the DOM, React can also invoke a "cleanup" function. That cleanup function can be returned from the useEffect
callback function. We don’t need a cleanup function for this particular case.
We would need a cleanup function if we want to "revert" the title to its initial value before the app is rendered. I’ve done exactly that in this article. |
Setting the title value for this app is a side effect that needs to be invoked each time the pure TodosLeft
component is re-rendered. Its only dependency is the count of active TODOs which is computed on each render.
Here’s how the useEffect
function can be used to implement the side effect of syncing the title:
// Class version activeTodosCount = () => Object.values(this.props.todos).filter(todo => !todo.done).length; componentDidMount() { document.title =Active TODOs: ${this.activeTodosCount()}
; } componentDidUpdate() { document.title =Active TODOs: ${this.activeTodosCount()}
; } // Hooks version const activeTodosCount = Object.values(todos).filter(todo => !todo.done).length; useEffect(() => { document.title =Active TODOs: ${activeTodosCount}
; }, [activeTodosCount]);
Note how in the hook version there is only one place for what was 2 places in the class version. The side effect is invoked after both the initial mount and after each update. We did not need a function to compute the active TODOs count. It’s only computed once, used in the returned output, and then used in the body of the callback function for the side effect hook (thanks to JavaScript function closures).
Convert the other 2 components in the App on your own. You can see the full converted version below. |
The core concepts in the hooks version
Here’s the full version of the example after converting all components with the simple strategy we used above. I used inline comments to further explain the new concepts used in this version. If you need to experiment with the code, the interactive version is included at the end of the lesson:
const TodosApp = ({ initialData }) => {
/* There is no "instance" concept when using React function components.
React just calls the functions each time it needs to render them.
The "props" data is passed to the functions as its only argument. */
const [todos, setTodos] = useState(initialData.todos);
const [filterLabel, setFilterLabel] = useState('All');
/* The state is externally managed in memory and
made accessible through the useState hook. */
const addNewTodo = newTodoBody =>
setTodos(prevTodos => ({
...prevTodos,
[uniqueId()]: {
body: newTodoBody,
done: false,
},
}));
const markTodoDone = (todoId, newDoneValue) =>
setTodos(prevTodos => ({
...prevTodos,
[todoId]: {
...prevTodos[todoId],
done: newDoneValue,
},
}));
const deleteTodo = todoId =>
setTodos(prevTodos => {
const { [todoId]: _, ...todos } = prevTodos;
return todos;
});
const deleteAllCompleteTodos = () =>
setTodos(prevTodos =>
Object.entries(prevTodos).reduce((acc, [todoId, todo)] => {
if (!todo.done) {
acc[todoId] = todo;
}
return acc;
}, {})
);
/* The 4 functions above are mutation functions. They update
state elements using their updater functions (setTodos).
By using a function as the argument to setTodos,
these functions don't need access to "current" value of the
todos state. This is better than using a direct value
for setTodos because it allows these mutation functions to be
memoized.
Note how the setFilter operation did not need a function here
because the setFilterLabel updater function can simply be used
directly in component JSX. 1 less function for the win. */
const shouldShowTodo = todo =>
filterLabel === 'All' ||
(filterLabel === 'Active' && !todo.done) ||
(filterLabel === 'Completed' && todo.done);
/* This function is a computation function. It currently depends
on reading the filterLabel state element directly in its
scope (using closures). This is not ideal as the function
itself need to be recreated every time the component renders.
It can be memoized to only get re-created when the filterLabel
element changes but since it is not passed down to any child
elements in JSX a memoization in this case is not necessary. */
return (
<>
<header>TODO List</header>
<ul>
{Object.entries(todos).map(
([todoId, todo]) =>
shouldShowTodo(todo) && (
<TodoItem
key={todoId}
id={todoId}
todo={todo}
markTodoDone={markTodoDone}
deleteTodo={deleteTodo}
/>
)
)}
</ul>
<AddTodoForm onSubmit={addNewTodo} />
<div className="actions">
Show:{' '}
<FilterButton
label="All"
onClick={setFilterLabel}
active={filterLabel === 'All'}
/>
<FilterButton
label="Active"
onClick={setFilterLabel}
active={filterLabel === 'Active'}
/>
<FilterButton
label="Completed"
onClick={setFilterLabel}
active={filterLabel === 'Completed'}
/>
</div>
<div className="actions">
<button onClick={deleteAllCompleteTodos}>
Delete All Completed
</button>
</div>
<footer>
<TodosLeft todos={todos} />
</footer>
</>
/* What's returned here is exactly equivalent to the class version except
instead of "this.something", we just use "something" directly. */
);
};
const TodoItem = ({ todo, markTodoDone, deleteTodo }) => {
const handleCheckboxChange = event => {
const newDone = event.target.checked;
markTodoDone(todo.id, newDone);
};
const handleXClick = () => {
deleteTodo(todo.id);
};
/* Both of the handlers above depend on "todo" which is a prop for
this component. This also means they have to be recreated every time
this component is re-rendered by its parent. */
const todoStyle = { textDecoration: todo.done ? 'line-through' : 'none' };
/* Style computing is done exactly the same except its now part of the
function component body. In class components, this line was in the
render function itself. */
return (
<li>
<input
type="checkbox"
checked={todo.done}
onChange={handleCheckboxChange}
/>
<span style={todoStyle}>{todo.body}</span>
<span role="link" onClick={handleXClick}>
X
</span>
</li>
);
};
const AddTodoForm = ({ onSubmit }) => {
const handleSubmit = event => {
event.preventDefault();
onSubmit(event.target.todoBody.value);
event.target.todoBody.value = '';
};
return (
<form onSubmit={handleSubmit}>
<input type="text" name="todoBody" placeholder="What TODO?" />
<button type="submit">Add TODO</button>
</form>
);
};
const FilterButton = ({ label, active, onClick }) => {
const handleClick = () => onClick(label);
const buttonStyle = {
fontWeight: active ? 'bold' : 'normal',
};
return (
<button onClick={handleClick} style={buttonStyle}>
{label}
</button>
);
};
/* Not much has changed for the AddTodoForm and FilterButton components
except for omitting this.props. The props are now destructured and
used directly in the body of the components' functions. */
const TodosLeft = ({ todos }) => {
const activeTodosCount =
Object.values(todos).filter(todo => !todo.done).length;
useEffect(() => {
document.title = Active TODOs: ${activeTodosCount}
;
}, [activeTodosCount]);
return <div>TODOs left: {activeTodosCount}</div>;
/* Each time the TodosLeft component is re-rendered, it'll compute
the new value of activeTodosCount and use it in the output.
The side effect hook function will also update the document
title with the new count on each render. \*/
};
This hooks version is about 150 LOC (compared to 180 LOC for the class version). HOWEVER, this is a VERY wrong metric to use to say that the hooks version is better. In fact, this hooks version introduced a few issues that were not in the class version. The good thing about this hooks version is that there is no confusing "this", which makes the code a little bit easier to deal with. However, the hooks version heavily depends on JavaScript closures and if you’re not comfortable with them you will write buggy React function components.
Check out the jsComplete interactive lab on closures at jscomplete.com/closures. |
The issues this hooks version introduced might not be significant for this little app but the transformation we’ve done so far is not complete. We will have to optimize function creation in the hooks version to make it completely equivalent to the class version. Let’s do that next.
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 ;)