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.