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 = () => {
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>
);
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 (previoushandleFormSubmit
≠ 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 incomponentWillUnmount
. 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
, andcomponentWillUnmount
. 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 theuseEffect
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.
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);
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) {
// 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}>
{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.
Top comments (0)