June 07, 2019
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.
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>
);
}
}
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>
);
};
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 = () => {
const [loading, setLoading] = userState(false); const [users, setUsers] = useState(); const [error, setError] = useState();
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>
);
};
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();
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] );
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
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();
setSearchValue(event.target.elements.username.value); },
[setSearchValue]
);
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]);
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.
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);
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`); }};
const UserSearch = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const handleFormSubmit = useCallback(
event => {
event.preventDefault();
dispatch({ type: SET_SEARCH_VALUE, payload: event.target.elements.username.value, }); },
[dispatch]
);
useEffect(() => {
let request;
if (state.searchValue) {
dispatch({ type: FETCH_INIT });
request = superagent.get(
`http://localhost:8080/users/${state.searchValue}`
);
request
.then(response => {
dispatch({ type: FETCH_SUCCESS, payload: response.body.items }); })
.catch(error => {
dispatch({ type: ERROR, payload: error }); });
}
return () => {
if (request) {
request.abort();
}
};
}, [state.searchValue, dispatch]);
return (
<form onSubmit={handleFormSubmit}>
{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> )} </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.
Feel free to send feedback or ask questions on Twitter.