Over the course of the past month, I've spent a lot of time working with React hooks, and building function components using hooks, instead of the traditional class-based components. I'm not going to claim that hooks are definitely superior to class-based components, but there is a huge usability boost, while writing your components as function components, especially since these function components can now access state and lifecycle through the use of React hooks.
In this article, I'm going to be showing you how to convert a class-based React component to a function component, replacing the class based setState
and lifecycle methods such as componentWillMount
, componentWillReceiveProps
with React hooks instead.
So let's first build a class-based React component, which makes use of state as well as lifecycle methods. For this, we shall of course build the traditional React Todo Application.
Our Todo app is going to look like this:
- A text input, into which you can type new todo items.
- An 'Add Todo' button, clicking which the new todo item in the text input, is added to the todo list.
- A list displaying each individual todo item.
- Each individual todo item has an associated checkbox which can be used to mark the item as completed.
- The todos are persisted to local storage, and loaded again from local storage when the app is initiated.
Our components will use state
, componentDidMount
, componentDidUpdate
and getDerivedStateFromProps
lifecycle methods. A few of these methods (I'm looking at you getDerivedStateFromProps
) are used in an extremely contrived manner, in order to be able to demonstrate how a hooks based replacement would look like.
Building the Class-Based Todo App
Our todo app will be implemented as three different components, like this:
The complete code for these examples can be found on Github. Use the tags to navigate between class components and function components.
Let's take a look at the TodoContainer
component first. This component maintains the complete state of the application, at any time in it's state
. It has two methods addTodoItem
and toggleItemCompleted
which are passed down as callbacks to the TodoInput
and TodoItem
components respectively, and are used to add a new todo item, and mark an item as completed or incomplete.
In addition, the TodoContainer
component makes use of the componentDidMount
lifecycle method to load saved todo items from the localStorage
. If there are no saved todo items, then an empty list is instantiated as the state of the TodoContainer
component. TodoContainer
also uses the componentDidUpdate
lifecycle method to save the state of the TodoContainer
component, to the localStorage
. This way, whenever there is a change to the state
of TodoContainer
it is persisted to localStorage
and can be restored upon relaunching the app.
Putting all of that together, TodoContainer
component looks like this:
/**
* Todo Root Container Component
*/
import React, { Component } from 'react';
import TodoItem from './TodoItem';
import TodoInput from './TodoInput';
class Todo extends Component {
constructor(props) {
super(props);
this.state = {
todoItems: [],
completedItemIds: []
};
}
generateID() {
return Date.now().toString(36) + '-' + (Math.random() + 1).toString(36).substring(7);
}
addTodoItem = (text) => {
const newTodoItem = {
text,
id: this.generateID()
};
const todoItems = this.state.todoItems.concat([newTodoItem]);
this.setState({ todoItems });
}
toggleItemCompleted = (todoItemId) => {
const todoItemIndexInCompletedItemIds = this.state.completedItemIds.indexOf(todoItemId);
const completedItemIds = todoItemIndexInCompletedItemIds === -1 ?
this.state.completedItemIds.concat([todoItemId]) :
([
...this.state.completedItemIds.slice(0, todoItemIndexInCompletedItemIds),
...this.state.completedItemIds.slice(todoItemIndexInCompletedItemIds + 1)
]);
this.setState({ completedItemIds });
}
componentDidMount() {
let savedTodos = localStorage.getItem('todos');
try {
savedTodos = JSON.parse(savedTodos);
this.setState(Object.assign({}, this.state, savedTodos));
} catch (err) {
console.log('Saved todos non-existent or corrupt. Trashing saved todos.');
}
}
componentDidUpdate() {
localStorage.setItem('todos', JSON.stringify(this.state));
}
render() {
const todoList = this.state.todoItems.map(todoItem => {
return (
<TodoItem
key={todoItem.id}
completedItemIds={this.state.completedItemIds}
toggleItemCompleted={this.toggleItemCompleted}
{...todoItem} />
);
});
const todoInput = (
<TodoInput
onAdd={this.addTodoItem} />
);
return (
<div
className="todo-container">
{todoList}
{todoInput}
</div>
);
}
};
export default Todo;
Next let's look at the TodoInput
component. This is a very simple component, which consists of a single text input and a button. The component uses it's own state
to keep track of the value of the text input, and upon button click, passes that text along to the addTodoItem
method passed in as the onAdd
prop by the TodoContainer
component.
The TodoInput
component looks like this:
/**
* TodoInput Component
*/
import React, { Component } from 'react';
class TodoInput extends Component {
constructor(props) {
super(props);
this.state = {
text: ''
};
}
onTextChange = (ev) => {
this.setState({ text: ev.currentTarget.value });
}
addTodoItem = () => {
this.props.onAdd(this.state.text);
this.setState({ text: '' });
}
render() {
return (
<div
className="todo-input">
<input
type="text"
onChange={this.onTextChange}
value={this.state.text}
placeholder="Enter Todo Here" />
<button
onClick={this.addTodoItem}>
Add Todo
</button>
</div>
);
}
};
export default TodoInput;
Finally, the TodoItem
component. This is another simple component, which basically consists of a checkbox, indicating whether the todo item is completed, and a label for that checkbox, specifying the text of the todo item. In order to demonstrate the use of getDerivedStateFromProps
, the TodoItem
component takes in the entire completedItemIds
from the TodoContainer
component as a prop, and uses it to calculate if this particular TodoItem
is complete or not.
The TodoItem
component looks like this:
/**
* TodoItem Component
*/
import React, { Component } from 'react';
class TodoItem extends Component {
constructor(props) {
super(props);
this.state = {
completed: false
};
}
toggleItemCompleted = () => {
this.props.toggleItemCompleted(this.props.id);
}
static getDerivedStateFromProps(props, state) {
const todoItemIndexInCompletedItemIds = props.completedItemIds.indexOf(props.id);
return { completed: todoItemIndexInCompletedItemIds > -1 };
}
render() {
return (
<div
className="todo-item">
<input
id={`completed-${this.props.id}`}
type="checkbox"
onChange={this.toggleItemCompleted}
checked={this.state.completed} />
<label>{this.props.text}</label>
</div>
);
}
};
export default TodoItem;
Rules to Remember While Getting Hooked
In order to demonstrate the various React hooks we are going to be using to convert our Todo app to use React function components, I'm going to be starting with the simple hooks, and moving on to the more complicated ones. But before we do that, let's take a quick look at the most important rules to keep in mind while using hooks.
- Hooks should only be called from within a React function component, or another hook.
- The same hooks should be called in the same order, the same number of times, during the render of a single component. This means that hooks cannot be called within loop or conditional blocks, and must instead always be called at the top level of the function.
The reasoning behind these rules for using hooks are a topic that can be an article by itself, and if you are interested in reading more about this, you should check out the Rules of Hooks article on the official React documentation site.
Now, that we know the basic rules to be followed while using hooks, let's go ahead and start converting our Todo app to use function components.
Function Components for our Todo App
The simplest, and probably the one hook that you are going to end up using the most is the useState
hook. The useState
hook basically provides you with a setter and a getter to manipulate a single state property on the component. The useState
hook has the following signature:
const [value, setValue] = useState(initialValue);
The first time the hook is called, the state item is initialized with the initialValue
. Upon subsequent calls to useState
, the previously set value will be returned, along with a setter method, which can be used to set a new value for that particular state property.
So let's convert the TodoInput
component to a function component, using the useState
hook.
/**
* TodoInput Component
*/
import React, { useState } from 'react';
function TodoInput({
onAdd
}) {
const [text, setText] = useState('');
const onTextChange = (ev) => setText(ev.currentTarget.value);
const addTodoItem = () => {
onAdd(text);
setText('');
};
return (
<div
className="todo-input">
<input
type="text"
onChange={onTextChange}
value={text}
placeholder="Enter Todo Here" />
<button
onClick={addTodoItem}>
Add Todo
</button>
</div>
);
};
export default TodoInput;
As you can see, we are using useState
to get the text
state property value and setText
setter, obtained from the useState
method. The onTextChange
and addTodoItem
methods have both been changed to use the setText
setter method, instead of setState
.
You might have noticed that the onChange
event handler for the input
, as well as the Add Todo
button onClick
event handlers are both anonymous functions, created during the render, and as you might know, this is not really good for performance, since the reference to the functions changes between renders, thus forcing React to re-render both the input
and the button
.
In order to avoid these unnecessary re-renders, we need to keep the reference to these functions the same. This is where the next hook we are going to use useCallback
comes into the picture. useCallback
has the following signature:
const memoizedFunction = useCallback(inlineFunctionDefinition, memoizationArguments);
where:
-
inlineFunctionDefinition
- is the function that you wish to maintain the reference to between renders. This can be an inline anonymous function, or a function that get's imported from somewhere else. However, since in most cases we would want to refer to state variables of the component, we will be defining this as an inline function, which will allow us to access the state variables using closures. -
memoizationArguments
- is an array of arguments that theinlineFunctionDefinition
function references. The first time theuseCallback
hook is called, thememoizationArguments
are saved along with theinlineFunctionDefinition
. Upon subsequent calls, each element in the newmemoizationArguments
array is compared to the value of the element at the same index in the previously savedmemoizationArguments
array, and if there is no change, then the previously savedinlineFunctionDefinition
is returned, thus preserving the reference, and preventing an unnecessary re-render. If any of the parameters have changed, then theinlineFunctionDefinition
and the newmemoizationArguments
are saved and used, thus changing the reference to the function, and ensuring a re-render.
So adapting TodoInput
to use useCallback
:
/**
* TodoInput Component
*/
import React, { useState, useCallback } from 'react';
function TodoInput({
onAdd
}) {
const [text, setText] = useState('');
const onTextChange = useCallback((ev) => setText(ev.currentTarget.value), [setText]);
const addTodoItem = useCallback(() => {
onAdd(text);
setText('');
}, [onAdd, text, setText]);
return (
<div
className="todo-input">
<input
type="text"
onChange={onTextChange}
value={text}
placeholder="Enter Todo Here" />
<button
onClick={addTodoItem}>
Add Todo
</button>
</div>
);
};
export default TodoInput;
Now that we have converted TodoInput
to a function component, let's do the same with TodoItem
. Rewriting using useState
and useCallback
, TodoItem
becomes:
/**
* TodoItem Component
*/
import React, { useState, useCallback } from 'react';
function TodoItem({
id,
text,
toggleItemCompleted,
completedItemIds
}) {
const [completed, setCompleted] = useState(false);
const onToggle = useCallback(() => {
toggleItemCompleted(id);
}, [toggleItemCompleted, id]);
return (
<div
className="todo-item">
<input
id={`completed-${id}`}
type="checkbox"
onChange={onToggle}
checked={completed} />
<label>{text}</label>
</div>
);
};
export default TodoItem;
If you compare this with the class component version of TodoItem
, you'll notice that the getDerivedStateFromProps
which we used to determine if this particular TodoItem
was completed or not, is missing in the function component version. So which hook would we use to implement that?
Well there exists no specific hook to implement this. Instead, we will have to implement this as part of the render function itself. So once we implement it, TodoItem
looks like this:
/**
* TodoItem Component
*/
import React, { useState, useCallback } from 'react';
function TodoItem({
id,
text,
toggleItemCompleted,
completedItemIds
}) {
const [completed, setCompleted] = useState(false);
const todoItemIndexInCompletedItemIds = completedItemIds.indexOf(id);
const isCompleted = todoItemIndexInCompletedItemIds > -1;
if (isCompleted !== completed) {
setCompleted(isCompleted);
}
const onToggle = useCallback(() => {
toggleItemCompleted(id);
}, [toggleItemCompleted, id]);
return (
<div
className="todo-item">
<input
id={`completed-${id}`}
type="checkbox"
onChange={onToggle}
checked={completed} />
<label>{text}</label>
</div>
);
};
export default TodoItem;
You might notice that we are calling the setCompleted
state setter method during the rendering of the component. While writing class components, we never call setState
in the render
method, so why is this acceptible in function components?
This is allowed in function components specifically to allow us to perform getDerivedStateFromProps
-esque actions. An important thing to keep in mind, is to ensure that, inside a function component, we always call state setter methods inside a conditional block. Otherwise, we are going to end up in an infinite loop.
Please note, the way I've implemented the
isCompleted
check here is a bit contrived, in order to demonstrate setting the state from within the function component. Ideally, thecompleted
state simply would not be used, and the calculatedisCompleted
value would be used to set thechecked
state of the checkbox.
Finally, we've only got TodoContainer
component to be converted. We'll need to implement state
as well as the componentDidMount
and componentDidUpdate
lifecycle methods.
Since we've already seen about useState
and I don't really have a good excuse to demonstrate useReducer
, I'm going to pretend that TodoContainer
component's state is too complicated to manage each state property individually using useState
, and that the better option is to use useReducer
.
The signature of the useReducer
hook, looks like this:
const [state, dispatch] = useReducer(reducerFunction, initialState, stateInitializerFunction);
where:
-
reducerFunction
- is the function that takes existingstate
and anaction
as an input and returns a newstate
as the output. This should be familiar to anybody who has used Redux and it's reducers. -
initialState
- If there is nostateInitializerFunction
provided, then this is the initialstate
object for the component. If astateInitializerFunction
is provided, this is passed in as an argument to that function. -
stateInitializerFunction
- A function which allows you to perform lazy initialization of the component's state. TheinitialState
parameter will be passed in as an argument to this function.
So converting TodoContainer
component to use useReducer
:
/**
* Todo Root Container Component
*/
import React, { useReducer, useCallback } from 'react';
import TodoItem from './TodoItem';
import TodoInput from './TodoInput';
const generateID = () => {
return Date.now().toString(36) + '-' + (Math.random() + 1).toString(36).substring(7);
};
const reducer = (state, action) => {
if (action.type === 'toggleItemCompleted') {
const { todoItemId } = action;
const todoItemIndexInCompletedItemIds = state.completedItemIds.indexOf(todoItemId);
const completedItemIds = todoItemIndexInCompletedItemIds === -1 ?
state.completedItemIds.concat([todoItemId]) :
([
...state.completedItemIds.slice(0, todoItemIndexInCompletedItemIds),
...state.completedItemIds.slice(todoItemIndexInCompletedItemIds + 1)
]);
return { ...state, completedItemIds };
}
if (action.type === 'addTodoItem') {
const newTodoItem = {
text: action.text,
id: generateID()
};
const todoItems = state.todoItems.concat([newTodoItem]);
return { ...state, todoItems };
}
return state;
};
const initialState = {
todoItems: [],
completedItemIds: []
};
function Todo() {
const [state, dispatch] = useReducer(reducer, initialState);
const toggleItemCompleted = useCallback((todoItemId) => {
dispatch({ type: 'toggleItemCompleted', todoItemId });
}, [dispatch]);
const todoList = state.todoItems.map(todoItem => {
return (
<TodoItem
key={todoItem.id}
completedItemIds={state.completedItemIds}
toggleItemCompleted={toggleItemCompleted}
{...todoItem} />
);
});
const addTodoItem = useCallback((text) => {
dispatch({ type: 'addTodoItem', text });
}, [dispatch]);
const todoInput = (
<TodoInput
onAdd={addTodoItem} />
);
return (
<div
className="todo-container">
{todoList}
{todoInput}
</div>
);
};
export default Todo;
Next we need to write the state of the TodoContainer
component to localStorage
, whenever the component is updated, similar to what we were doing in componentDidUpdate
. To do this, we will make use of the useEffect
hook. The useEffect
hook allows us to enqueue a certain action to be done, after each render of the component is completed. It's signature is:
useEffect(enqueuedActionFunction);
As long as you adhere to the rules of hooks, we talked about earlier, you can insert a useEffect
block anywhere in your function component. If you have multiple useEffect
blocks, they will be executed in sequence. The general idea of useEffect
is to perform any actions that do not directly impact the component in the useEffect
block (ex: API calls, DOM manipulations etc.).
We can use useEffect
to ensure that after every single render of our TodoContainer
, the state of the component is written to localStorage
.
/**
* Todo Root Container Component
*/
import React, { useReducer, useCallback, useEffect } from 'react';
import TodoItem from './TodoItem';
import TodoInput from './TodoInput';
const generateID = () => {
return Date.now().toString(36) + '-' + (Math.random() + 1).toString(36).substring(7);
};
const reducer = (state, action) => {
if (action.type === 'toggleItemCompleted') {
const { todoItemId } = action;
const todoItemIndexInCompletedItemIds = state.completedItemIds.indexOf(todoItemId);
const completedItemIds = todoItemIndexInCompletedItemIds === -1 ?
state.completedItemIds.concat([todoItemId]) :
([
...state.completedItemIds.slice(0, todoItemIndexInCompletedItemIds),
...state.completedItemIds.slice(todoItemIndexInCompletedItemIds + 1)
]);
return { ...state, completedItemIds };
}
if (action.type === 'addTodoItem') {
const newTodoItem = {
text: action.text,
id: generateID()
};
const todoItems = state.todoItems.concat([newTodoItem]);
return { ...state, todoItems };
}
return state;
};
const initialState = {
todoItems: [],
completedItemIds: []
};
function Todo() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(state));
});
const toggleItemCompleted = useCallback((todoItemId) => {
dispatch({ type: 'toggleItemCompleted', todoItemId });
}, [dispatch]);
const todoList = state.todoItems.map(todoItem => {
return (
<TodoItem
key={todoItem.id}
completedItemIds={state.completedItemIds}
toggleItemCompleted={toggleItemCompleted}
{...todoItem} />
);
});
const addTodoItem = useCallback((text) => {
dispatch({ type: 'addTodoItem', text });
}, [dispatch]);
const todoInput = (
<TodoInput
onAdd={addTodoItem} />
);
return (
<div
className="todo-container">
{todoList}
{todoInput}
</div>
);
};
export default Todo;
Next we need to restore the state from the localStorage
whenever the component is mounted, similar to what we do in componentDidMount
. Now there are no specific hooks to perform this, but we can use the useReducer
hooks lazy initialization function (which will be called only on the first render), to achieve this.
/**
* Todo Root Container Component
*/
import React, { useReducer, useCallback, useEffect } from 'react';
import TodoItem from './TodoItem';
import TodoInput from './TodoInput';
const generateID = () => {
return Date.now().toString(36) + '-' + (Math.random() + 1).toString(36).substring(7);
};
const reducer = (state, action) => {
if (action.type === 'toggleItemCompleted') {
const { todoItemId } = action;
const todoItemIndexInCompletedItemIds = state.completedItemIds.indexOf(todoItemId);
const completedItemIds = todoItemIndexInCompletedItemIds === -1 ?
state.completedItemIds.concat([todoItemId]) :
([
...state.completedItemIds.slice(0, todoItemIndexInCompletedItemIds),
...state.completedItemIds.slice(todoItemIndexInCompletedItemIds + 1)
]);
return { ...state, completedItemIds };
}
if (action.type === 'addTodoItem') {
const newTodoItem = {
text: action.text,
id: generateID()
};
const todoItems = state.todoItems.concat([newTodoItem]);
return { ...state, todoItems };
}
return state;
};
const initialState = {
todoItems: [],
completedItemIds: []
};
const initState = (state) => {
let savedTodos = localStorage.getItem('todos');
try {
savedTodos = JSON.parse(savedTodos);
return Object.assign({}, state, savedTodos);
} catch (err) {
console.log('Saved todos non-existent or corrupt. Trashing saved todos.');
return state;
}
};
function Todo() {
const [state, dispatch] = useReducer(reducer, initialState, initState);
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(state));
});
const toggleItemCompleted = useCallback((todoItemId) => {
dispatch({ type: 'toggleItemCompleted', todoItemId });
}, [dispatch]);
const todoList = state.todoItems.map(todoItem => {
return (
<TodoItem
key={todoItem.id}
completedItemIds={state.completedItemIds}
toggleItemCompleted={toggleItemCompleted}
{...todoItem} />
);
});
const addTodoItem = useCallback((text) => {
dispatch({ type: 'addTodoItem', text });
}, [dispatch]);
const todoInput = (
<TodoInput
onAdd={addTodoItem} />
);
return (
<div
className="todo-container">
{todoList}
{todoInput}
</div>
);
};
export default Todo;
As you can see, we read the saved previous state from localStorage
, and use that to initilize the state
of the TodoContainer
component, thus mimicking what we were doing in componentDidMount
. And with that, we have converted our entire todo app, to function components.
In conclusion...
In this article, I've only covered a few of the hooks that are available out of the box in React. Apart from these out of the box hooks, we can also write our own custom hooks to perform more specific tasks.
One of the key takeaways from this little exercise in converting class components to function components, is that there is no one to one mapping between lifecycle methods and React hooks. While useState
and useReducer
usage is quite similar to setState
, you might have to change the way you are doing certain tasks in order to get them working in a function component (like getDerivedStateFromProps
in our Todo app).
As such converting a React class component to a function component is something that may be trivial, or quite complicated depending on the component. Unless you're writing a new component, there really is no hard need to convert a class component to a function component, especially since React is going to continue supporting both forms for the foreseeable future.
That said, if you do find yourself needing to convert an existing class component to a function component, I hope this article provided you with a good starting point. I'd love to hear your opinion on things I've written in this article, so do chime in below.
This article was originally published at asleepysamurai.com
Top comments (0)