Antoine Lehurt

React Hooks by example: useState, useCallback, useEffect, useReducer

In this article, we will touch upon how to use useCallback, useEffect, useReducer and useState hooks.

We will build a component that gives the user the ability to search for a list of users. The component will store the data about the request state (if it’s loading) and response (the user list or the error information). It will listen for the form submit event and call the backend with the input’s value to get the list of users. There are different ways to achieve it, such as using Redux, but we will keep it basic since we will focus on the hooks.

The class way (without hooks)

Using a class component, it could look like this:

class UserSearch extends React.Component {
  constructor(props, ...rest) {
    super(props, ...rest);

    this.state = {
      loading: false,
      error: undefined,
      users: undefined,
    };
  }

  componentWillUnmount() {
    if (this.request) {
      this.request.abort();
    }
  }

  handleFormSubmit = (event) => {
    this.setState({ loading: true });

    this.request = superagent.get(`http://localhost:8080/users/${event.target.elements.username.value}`);
    this.request
      .then((response) => {
        this.setState({
          loading: false,
          users: response.body.items,
        });
      })
      .catch((error) => {
        this.setState({
          loading: false,
          error,
        });
      });
  };

  render() {
    const { loading, error, users, searchValue } = this.state;

    return (
      <form onSubmit={this.handleFormSubmit}>
        {error && <p>Error: {error.message}</p>}

        <input type="text" name="username" disabled={loading} />
        <button type="submit" disabled={loading}>
          Search
        </button>

        {loading && <p>Loading...</p>}

        {users && (
          <div>
            <h1>Result</h1>
            <ul>
              {users.map(({ id, name }) => (
                <li key={id}>{name}</li>
              ))}
            </ul>
          </div>
        )}
      </form>
    );
  }
}

The functional way

We will refactor the UserSearch component step by step and introduce the hooks on the way.

We no longer need to use classes when we use hooks. The first step is to extract the render method into a function based component. We also inline the state and the event handlers, but currently, they don’t do anything.

const UserSearch = () => {
  const loading = false;
  const users = undefined;
  const error = undefined;

  const handleFormSubmit = () => {
    // TODO
  };

  return (
    <form onSubmit={handleFormSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input type="text" name="username" disabled={loading} />
      <button type="submit" disabled={loading}>
        Search
      </button>

      {loading && <p>Loading...</p>}

      {users && (
        <div>
          <h1>Result</h1>
          <ul>
            {users.map(({ id, name }) => (
              <li key={id}>{name}</li>
            ))}
          </ul>
        </div>
      )}
    </form>
  );
};

Introducing hooks

useState

We can use the useState hook to store the different states we have in our component (loading, users, error). useState takes the initial value as a parameter and returns a tuple of the state value and a function to update the value.

const [value, setValue] = useState(initialValue);

Let’s update our states using setState. Currently, we only initialize the states, but we need to implement the logic.

const UserSearch = () => {
  // highlight-start
  const [loading, setLoading] = userState(false);
  const [users, setUsers] = useState();
  const [error, setError] = useState();
  // highlight-end

  const handleFormSubmit = () => {
    // TODO
  };

  return (
    <form onSubmit={handleFormSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input type="text" name="username" disabled={loading} />
      <button type="submit" disabled={loading}>
        Search
      </button>

      {loading && <p>Loading...</p>}

      {users && (
        <div>
          <h1>Result</h1>
          <ul>
            {users.map(({ id, name }) => (
              <li key={id}>{name}</li>
            ))}
          </ul>
        </div>
      )}
    </form>
  );
};

useCallback

A function-based component doesn’t have lifecycles and React calls the function for each new render, which means that for each re-render every hoisted object will be recreated. For instance, a new handleFormSubmit function is created every time. One of the issues is that it invalidates the tree because <form onSubmit={handleFormSubmit}> is different between renders (previous handleFormSubmit ≠ next handleFormSubmit because () => {} !== () => {}).

That’s where useCallback comes into play. It caches the function and creates a new one only if a dependency changes. A dependency is a value that is created in the component but is outside the useCallback scope.

const fn = useCallback(() => {}, [dependencies]);

In the documentation, they recommend “every value referenced inside the callback should also appear in the dependencies array.” Although, you may omit dispatch (from useReducer), setState, and useRef container values from the dependencies because React guarantees them to be static. However, it doesn’t hurt to specify them. Note that If we pass an empty array for the dependencies, it will always return the same function.

I recommend you to use eslint-plugin-react-hooks to help you to know which values we need to include in the dependencies.

You should also check the article written by Kent C. Dodds about when to use useCallback since it also comes with a performance cost to use it over an inline callback. Spoiler: for referential equality and dependencies lists.

So, if we follow how it was done with the class, we could execute the GET request directly in the useCallback.

const UserSearch = () => {
  const [loading, setLoading] = userState(false);
  const [users, setUsers] = useState();
  const [error, setError] = useState();

  // highlight-start
  const handleFormSubmit = useCallback(
    (event) => {
      event.preventDefault();

      setLoading(true);

      const request = superagent.get(`http://localhost:8080/users/${event.target.elements.username.value}`);
      request
        .then((response) => {
          setLoading(false);
          setUsers(response.body.items);
        })
        .catch((error) => {
          setLoading(false);
          setError(error);
        });
    },
    [setLoading, setUsers, setError]
  );
  // highlight-end

  return (
    <form onSubmit={handleFormSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input type="text" name="username" disabled={loading} />
      <button type="submit" disabled={loading}>
        Search
      </button>

      {loading && <p>Loading...</p>}

      {users && (
        <div>
          <h1>Result</h1>
          <ul>
            {users.map(({ id, name }) => (
              <li key={id}>{name}</li>
            ))}
          </ul>
        </div>
      )}
    </form>
  );
};

⚠️ It works, there are few issues by doing that. When React unmounts the component, nothing aborts the request the same way we did in componentWillUnmount. Also, since the request is pending React keeps a reference to an unmounted component. So, it wastes browser resources for something the user will never interact with.

useEffect

useEffect brings the lifecycle to a function based component. It is the combination of componentDidMount, componentDidUpdate, and componentWillUnmount. The callback of useEffect is executed when a dependency is updated. So, the first time the component is rendered, useEffect will be executed. In our case, we want to start the request when the search value is updated (on form submit). We will introduce a new state searchValue that is updated in the handleFormSubmit handler and we will use that state as a dependency to the hook. Therefore when searchValue is updated the useEffect hook will also be executed.

Finally, the useEffect callback must return a function that is used to clean up, for us this is where we will abort the request.

const UserSearch = () => {
  const [loading, setLoading] = userState(false);
  const [users, setUsers] = useState();
  const [error, setError] = useState();
  const [searchValue, setSearchValue] = useState();

  const handleFormSubmit = useCallback(
    (event) => {
      event.preventDefault();
      // highlight-start
      setSearchValue(event.target.elements.username.value);
      // highlight-end
    },
    [setSearchValue]
  );

  // highlight-start
  useEffect(() => {
    let request;

    if (searchValue) {
      setLoading(true);

      request = superagent.get(`http://localhost:8080/users/${event.target.elements.username.value}`);
      request
        .then((response) => {
          setError(undefined);
          setLoading(false);
          setUsers(response.body.items);
        })
        .catch((error) => {
          setLoading(false);
          setError(error);
        });
    }

    return () => {
      if (request) {
        request.abort();
      }
    };
  }, [searchValue, setLoading, setUsers, setError]);
  // highlight-end

  return (
    <form onSubmit={handleFormSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input type="text" name="username" disabled={loading} />
      <button type="submit" disabled={loading}>
        Search
      </button>

      {loading && <p>Loading...</p>}

      {users && (
        <div>
          <h1>Result</h1>
          <ul>
            {users.map(({ id, name }) => (
              <li key={id}>{name}</li>
            ))}
          </ul>
        </div>
      )}
    </form>
  );
};

Dan Abramov has written an excellent blog post about useEffect hooks: a complete guide to useEffect.

useReducer

We have now a working version of our component using React Hooks 🎉. One thing we could improve is when we have to keep track of several states, such as in the request’s response we update three states. In our example, I think it’s fine to go with the current version. However, in the case we need to add more states, useReducer would be a better suit. That allows us to gather related states in the same area of our code and have one way to update the states.

useReducer expects a reducer function (that function takes an action and returns a new state) and the initial state. Similar to useState it returns a tuple that contains the state and the dispatch function that we use to dispatch actions.

const [state, dispatch] = useReducer(reducer, initialState);
// highlight-start
const initialState = {
  loading: false,
  users: undefined,
  error: undefined,
  searchValue: undefined,
};

const SET_SEARCH_VALUE = 'SET_SEARCH_VALUE';
const FETCH_INIT = 'FETCH_INIT';
const FETCH_SUCCESS = 'FETCH_SUCCESS';
const ERROR = 'ERROR';

const reducer = (state, { type, payload }) => {
  switch (type) {
    case SET_SEARCH_VALUE:
      return {
        ...state,
        searchValue: payload,
      };

    case FETCH_INIT:
      return {
        ...state,
        error: undefined,
        loading: true,
      };

    case FETCH_SUCCESS:
      return {
        ...state,
        loading: false,
        error: undefined,
        result: payload,
      };

    case ERROR:
      return {
        ...state,
        loading: false,
        error: payload,
      };

    default:
      throw new Error(`Action type ${type} unknown`);
  }
};
// highlight-end

const UserSearch = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  const handleFormSubmit = useCallback(
    (event) => {
      event.preventDefault();

      // highlight-start
      dispatch({
        type: SET_SEARCH_VALUE,
        payload: event.target.elements.username.value,
      });
      // highlight-end
    },
    [dispatch]
  );

  useEffect(() => {
    let request;

    if (state.searchValue) {
      // highlight-next-line
      dispatch({ type: FETCH_INIT });

      request = superagent.get(`http://localhost:8080/users/${state.searchValue}`);
      request
        .then((response) => {
          // highlight-next-line
          dispatch({ type: FETCH_SUCCESS, payload: response.body.items });
        })
        .catch((error) => {
          // highlight-next-line
          dispatch({ type: ERROR, payload: error });
        });
    }

    return () => {
      if (request) {
        request.abort();
      }
    };
  }, [state.searchValue, dispatch]);

  return (
    <form onSubmit={handleFormSubmit}>
      {/* highlight-start */}
      {state.error && <p>Error: {state.error.message}</p>}

      <input type="text" name="username" disabled={state.loading} />
      <button type="submit" disabled={state.loading}>
        Search
      </button>

      {state.loading && <p>Loading...</p>}

      {state.users && (
        <div>
          <h1>Result</h1>
          <ul>
            {state.users.map(({ id, name }) => (
              <li key={id}>{name}</li>
            ))}
          </ul>
        </div>
      )}
      {/* highlight-end */}
    </form>
  );
};

As mentioned before, the benefits are not directly apparent since we don’t have that many states to handle in our example. There is more boilerplate than the useState version, but all states related to calling the API are managed in the reducer function.