I recently picked up an old project from two years ago. The app is not a very complicated one - it reads data from a simple API server and present them to the users, pretty standard stuff. The client has been pretty happy about the results so now they have come back with more feature requirements they'd like to include in the next iteration.
The Old Fashioned Way
Before actually start working on those features, I decided to bring all the dependencies up-to-date (it was still running React 16.2
- feels like eons ago) and do some "house cleaning". I'm glad that me from 2 years ago took the time to write plenty of unit and integration tests so this process was mostly painless. However when I was migrating those old React lifecycle functions (componentWill*
series) to newer ones, a familiar pattern emerged:
class FooComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
isLoading: true,
error: null,
data: null,
};
}
componentDidMount() {
this.fetchData();
}
componentDidUpdate(prevProps) {
if (prevProps.fooId !== this.props.fooId) {
this.fetchData();
}
}
fetchData() {
const url = compileFooUrl({ fooId: this.props.fooId });
fetch(url).then(
// set data on state
).catch(
// set error on state
);
}
render() {
// redacted.
}
}
Does this look familiar to you? The FooComponent
here fetches foo
from a remote source and renders it. A new foo
will be fetched when the fooId
in the props changes. We are also using some state field to track the request and the data fetched.
In this app I'm trying to improve, this pattern is seen in multiple components, but before hooks
, it's often not very straight forward to share logic like this, but not anymore! Let's try to create a re-usable hook to improve our code.
First Iteration With Hooks
Now before we actually write a reusable custom hook, let's try to refactor this component. I think it's pretty obvious that we are going to need useState
to replace this.state
and let useEffect
handle the data fetching part. useState
is pretty easy to handle, but if you are not familiar with useEffect
yet, Dan Abramov has a really good (and lengthy) blog article about it: https://overreacted.io/a-complete-guide-to-useeffect/
Our hooked component now looks like this:
const FooComponent = ({ fooId }) => {
const [state, setState] = useState({
isLoading: true,
error: null,
data: null,
});
useEffect(() => {
const url = compileFooUrl({ fooId });
fetch(url)
.then((response) => {
if (response.ok) {
return response.json().then(data => {
setState({
isLoading: false,
data,
error: null,
});
});
}
return Promise.reject(response);
})
.catch(/* similar stuff here */);
}, [fooId]);
return (
// redacted
);
};
Pretty easy, eh? Our component now works almost* exactly like before with fewer lines (and cooler hook functions!), and all integration tests are still green! It fetches foo
when it mounts and re-fetches it when fooId
changes.
- "almost" -> The component is now a function component which cannot take a
ref
. https://reactjs.org/docs/refs-and-the-dom.html#accessing-refs
Making Our Logic Re-useable
The next step would be making this fetch-and-set-state logic re-usable. Luckily it is extremely easy to write a custom hook - we just need to cut-and-paste our code to a separate file!
Let's name our reusable hook useGet
, which takes an url
- since apparently not all components are gonna use foo
and not all getRequests depend on a single ID I think it's probably easier to leave that url-building logic to each component that wants to use our custom hook. Here's what we are aiming for:
const FooComponent = ({ fooId }) => {
const fooUrl = compileFooUrl({ fooId: this.props.fooId });
const { isLoading, data, error } = useGet({ url });
return (
// same jsx as before
);
};
Let's cut-and-paste:
export function useGet = ({ url }) => {
const [state, setState] = useState({
isLoading: true,
error: null,
data: null,
});
useEffect(() => { /* fetch logic here */}, [url]);
// return the `state` so it can be accessed by the component that uses this hook.
return state;
};
By the way, then/catch
is so 2017, let's use async/await
instead to reduce the nested callbacks - everybody hates those. Unfortunately useEffect
cannot take an async function at this moment, we will have to define an async function inside of it, and call it right away. Our new useEffect
looks something like this:
useEffect(() => {
const fetchData = async () => {
setState({
isLoading: true,
data: null,
error: null,
});
try {
const response = await fetch(url);
if (!response.ok) {
// this will be handled by our `catch` block below
throw new Error(`Request Error: ${response.status}`);
}
setState({
isLoading: false,
data: await response.json(),
error: null,
});
} catch(e) {
setState({
isLoading: false,
data: null,
error: e.message,
});
}
};
fetchData();
}, [url]);
Much easier to read, isn't it?
The Problem with useState
In simple use cases like we have above, useState
is probably fine, however there is a small problem with our code: we have to provide values to all of the fields in the state object every time we want to use setState
. And sometimes, we don't necessarily want to reset other fields when a new request is fired (e.g. in some cases we might still want the user to be able to see the previous error message or data when a new request fires). You might be tempted to do this:
setState({
...state,
isLoading: true,
})
However that means state
also becomes a dependency of useEffect
- and if you add it to the array of dependencies, you will be greeted with an infinite fetch loop because every time state
changes, React will try to call the effect (which in turn, produces a new state).
Luckily we have useReducer
- it's somewhat similar to useState
here but it allows you to separate your state-updating logic from your component. If you have used redux
before, you already know how it works.
If you are new to the concept, you can think a reducer
is a function that takes a state
and an action
then returns a new state
. and useReducer
is a hook that let you define an initial state, a "reducer" function that will be used to update the state. useReducer
returns the most up-to-date state and a function that you will be using to dispatch actions.
const [state, dispatch] = useReducer(reducerFunction, initialState);
Now in our use case here, we've already got our initialState
:
{
isLoading: false,
data: null,
error: null,
}
And our state object is updated when the following action happens:
- Request Started (sets
isLoading
to true) - Request Successful
- Request Failed
Our reducer function should handle those actions
and update the state accordingly. In some actions, (like "request successful") we will also need to provide some extra data to the reducer so it can set them onto the state object. An action
can be almost any value (a string, a symbol, or an object), but in most cases we use objects with a type
field:
// a request successful action:
{
type: 'Request Successful', // will be read by the reducer
data, // data from the api
}
To dispatch an action, we simply call dispatch
with the action object:
const [state, dispatch] = useReducer(reducer, initialState);
// fetch ... and dispatch the action below when it is successful
dispatch({
type: 'Request Successful'
data: await response.json(),
});
And usually, we use "action creators" to generate those action
objects so we don't need to construct them everywhere. Action creators also makes our code easier to change if we want to add additional payloads to an action, or rename type
s.
// example of action creator:
// a simple function that takes some payload, and returns an action object:
const requestSuccessful = ({ data }) => ({
type: 'Request Successful',
data,
});
Often to avoid typing each type
string again and again - we can define them separately as constants, so both the action creators and the reducers can re-use them. Typos are very common in programming - typos in strings are often harder to spot, but if you make a typo in a variable or a function call, your editors & browsers will alert you right away.
// a contants.js file
export const REQUEST_STARTED = 'REQUEST_STARTED';
export const REQUEST_SUCCESSFUL = 'REQUEST_SUCCESSFUL';
export const REQUEST_FAILED = 'REQUEST_FAILED';
export const RESET_REQUEST = 'RESET_REQUEST';
// action creators:
export const requestSuccessful = ({ data }) => ({
type: REQUEST_SUCCESSFUL,
data,
});
// dispatching an action in our component:
dispatch(requestSuccessful({ data: await response.json() }));
Now, onto our reducer - it updates the state accordingly for each action
:
// reducer.js
// a reducer receives the current state, and an action
export const reducer = (state, action) => {
// we check the type of each action and return an updated state object accordingly
switch (action.type) {
case REQUEST_STARTED:
return {
...state,
isLoading: true,
};
case REQUEST_SUCCESSFUL:
return {
...state,
isLoading: false,
error: null,
data: action.data,
};
case REQUEST_FAILED:
return {
...state,
isLoading: false,
error: action.error,
};
// usually I ignore the action if its `type` is not matched here, some people prefer throwing errors here - it's really up to you.
default:
return state;
}
};
Putting it together, our hook now looks like this:
// import our action creators
import {
requestStarted,
requestSuccessful,
requestFailed,
} from './actions.js';
import { reducer } from './reducer.js';
export const useGet = ({ url }) => {
const [state, dispatch] = useReducer(reducer, {
isLoading: true,
data: null,
error: null,
});
useEffect(() => {
const fetchData = async () => {
dispatch(requestStarted());
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`${response.status} ${response.statusText}`
);
}
const data = await response.json();
dispatch(requestSuccessful({ data }));
} catch (e) {
dispatch(requestFailed({ error: e.message }));
}
};
fetchData();
}, [url]);
return state;
};
dispatch
is guaranteed to be stable and won't be changed between renders, so it doesn't need to be a dependency of useEffect
. Now our hook is much cleaner and easier to be reasoned with.
Now we can start refactoring other components that uses data from a remote source with our new hook!
But There Is More
We are not done yet! However this post is getting a bit too long. Here's the list of things I'd like to cover in a separate article:
- Clean up our effect
- Use hooks in class-components.
- Testing our hooks.
- A "re-try" option. Let's give the user an option to retry when a request fails - how do we do that with our new hook?
Stay tuned!
Top comments (2)
case REQUEST_STARTED:
// Avoid updating state unnecessarily
// By returning unchanged state React won't re-render
if (state.isLoading) {
return state;
}
return {
...state,
isLoading: true,
};
Hi - thanks for the comment.
personally i don't think that's necessary - if
REQUEST_STARTED
is fired multiple times when it is already loading, I would think there are more serious problems in other parts of the app, not just in the reducer.