Data fetching is a common task, that we normally perform when building a component. In this post we'll see how to turn a class-based component into a functional one, which relies on hooks for fetching data.
The use case
Let's suppose that we're building a PostList component, which retrieves data from an API, and manages the following states:
- Empty state
- Error state
- Loading state
The code for our PostList component, would be the following:
// PostList.js
import 'whatwg-fetch';
import 'abortcontroller-polyfill';
import React from 'react';
import Post from '@components/Post';
import Container from '@components/Container';
export default class PostList extends React.Component {
static displayName = 'PostList';
static defaultProps = {
url: 'https://jsonplaceholder.typicode.com/posts',
};
// Create an AbortController instance
abortController = new AbortController();
state = {
isLoading: true,
posts: [],
error: null,
};
componentDidMount() {
this.makeFetchHappen(this.props.url);
}
componentDidUpdate(prevProps) {
if (this.props.url !== prevProps.url) {
this.makeFetchHappen(this.props.url);
}
}
componentWillUnmount() {
if (this.abortController) {
// Cancel request when component unmounts
this.abortController.abort();
this.abortController = null;
}
}
render() {
const { isLoading, posts, error } = this.state;
return (
<Container
isLoading={isLoading}
isError={!isLoading && error}
isEmpty={!isLoading && posts && posts.length === 0}
>
{posts.map((post, idx) => (
<Post key={`Post.${idx}`} {...post} />
))}
</Container>
);
}
makeFetchHappen = async url => {
try {
if (!url) {
throw new Error(`'url' is required for data-fetching`);
}
const { signal } = this.abortController;
// Setup cancellation by passing the signal inside AbortController instance
const response = await fetch(url, { signal });
const posts = await response.json();
this.setState({ posts });
} catch (error) {
this.setState({ error });
} finally {
this.setState({ isLoading: false });
}
};
}
As you can see, the component initializes its own state for managing the different phases for rendering. The data-fetching requirement is met during componentDidMount, and in the case of url changing when passed as prop to PostList, we also define the componentDidUpdate callback, which will retry data-fetching.
The problem
When working in real apps you don't often rely on one data-fetching call — you'll have multiple ones. Also, multiple components will be fetching data to meet their own requirements.
Our current solution is attached to only one specific use case. If we want to translate this to another use case, like getting a post by its own id, we'll have to make another component and replicate some part of the data-fetching logic that needs to be in another part, not in the component itself.
The partial solution
When getting into this problem, the most common solution is to make a generic fetch method, and create a service, like in this example:
// services.js
import 'whatwg-fetch';
import 'abortcontroller-polyfill';
const API_URL = 'https://jsonplaceholder.typicode.com';
const doFetch = async (url, options) => {
try {
if (!url) {
throw new Error(`'url' is required for data-fetching`);
}
const response = await fetch(url, options);
const data = await response.json();
return [false, data, null];
} catch (error) {
return [false, null, error];
}
};
export const getPosts = (ctx) => {
return doFetch(`${API_URL}/posts`, ctx);
};
export const getPostById = (ctx) => {
return doFetch(`${API_URL}/${ctx.id}`, ctx);
};
And then, you would rewrite your PostList component like the following:
import React from 'react';
import Post from '@components/Post';
import Container from '@components/Container';
import { getPosts } from '@services';
export default class PostList extends React.Component {
static displayName = 'PostList';
// Create an AbortController instance
abortController = new AbortController();
state = {
isLoading: true,
posts: [],
error: null,
};
componentDidMount() {
this.makeFetchHappen({ signal: this.abortController.signal });
}
componentWillUnmount() {
if (this.abortController) {
// Cancel request when component unmounts
this.abortController.abort();
this.abortController = null;
}
}
render() {
const { isLoading, posts, error } = this.state;
return (
<Container
isLoading={isLoading}
isError={!isLoading && error}
isEmpty={!isLoading && posts && posts.length === 0}
>
{posts.map((post, idx) => (
<Post key={`Post.${idx}`} {...post} />
))}
</Container>
);
}
makeFetchHappen = async ctx => {
const [isLoading, posts, error] = await getPosts(ctx);
this.setState({
isLoading,
posts,
error,
});
};
}
But why a partial solution?
As you can see above, the developer that works with this code will need to think about things related to data-fetching and component state, like:
- Implementing request cancellation
- Coupling to React lifecycle callbacks
- Updating state based on the data coming from the service call
This is a partial solution because it will keep us thinking and working on things that we might get wrong.
The final solution
A better solution to this issue arrived with React v16.8. Using React Hooks for data-fetching would make you forget about things like the ones I mentioned above.
You can make a custom hook for data-fetching, like the following one:
// useFetch.js
import { useEffect, useRef, useState } from 'react';
const log = (...args) => console.warn(...args);
const useFetch = (url, options) => {
const abortControllerRef = useRef(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [response, setResponse] = useState(null);
useEffect(() => {
abortControllerRef.current = new AbortController();
});
useEffect(() => {
const { signal } = abortControllerRef.current;
const makeFetchHappen = async () => {
try {
if (!url) {
throw new Error(`'url' is required for fetching data`);
}
const response = await fetch(url, { ...options, signal });
let data;
try {
data = await response.json();
} catch (error) {
log(`useFetch: can't parse JSON, trying parsing response as text`);
data = await response.text();
} finally {
setResponse(data);
}
} catch (error) {
log(`useFetch: ${error.message}`);
setError(error);
} finally {
setLoading(false);
}
};
makeFetchHappen();
return () => abortControllerRef.current.abort();
}, [url, options]);
return [loading, response, error];
};
export default useFetch;
And then, you would convert your class-based component to a functional one:
// PostList.js
import 'whatwg-fetch';
import 'abortcontroller-polyfill';
import React from 'react';
import Post from '@components/Post';
import Container from '@components/Container';
import useFetch from '@hooks/useFetch';
const PostList = props => {
const [isLoading, posts, error] = useFetch(
'https://jsonplaceholder.typicode.com/posts'
);
return (
<Container
isLoading={isLoading}
isError={!isLoading && error}
isEmpty={!isLoading && posts && posts.length === 0}
>
{posts &&
posts.map((post, idx) => <Post key={`Post.${idx}`} {...post} />)}
</Container>
);
};
PostList.displayName = 'PostList';
export default PostList;
As you can see, we've abstracted all the stuff in our custom useFetch hook. Our custom hook will be in charge of:
- Managing different states for our data requirements
- Fetching data in
componentDidMount/componentDidUpdate - Managing request cancellation when our component using this hook unmounts
We could increase the complexity of this hook by adding support for:
- Managing GraphQL queries
- Caching results from an API for faster updates
- Parsing different data types when calling an API
- Making fetching data optional in React lifecycle via
useEffect
But for the educational purposes of this post we won't make those changes.
Well, that's all, I hope you've enjoyed it. If you really did, just leave a reply or reaction — I would like to hear feedback from you.