DEV Community

Cover image for Converting React Class Components to Function Components using Hooks
Balaganesh Damodaran
Balaganesh Damodaran

Posted on • Originally published at asleepysamurai.com

Converting React Class Components to Function Components using Hooks

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:

App Structure

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 the inlineFunctionDefinition function references. The first time the useCallback hook is called, the memoizationArguments are saved along with the inlineFunctionDefinition. Upon subsequent calls, each element in the new memoizationArguments array is compared to the value of the element at the same index in the previously saved memoizationArguments array, and if there is no change, then the previously saved inlineFunctionDefinition is returned, thus preserving the reference, and preventing an unnecessary re-render. If any of the parameters have changed, then the inlineFunctionDefinition and the new memoizationArguments 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, the completed state simply would not be used, and the calculated isCompleted value would be used to set the checked 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 existing state and an action as an input and returns a new state as the output. This should be familiar to anybody who has used Redux and it's reducers.
  • initialState - If there is no stateInitializerFunction provided, then this is the initial state object for the component. If a stateInitializerFunction 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. The initialState 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)