How to fetch data in React using Hooks

Why use Hooks?

Class components are verbose and cumbersome. In many cases, we are forced to duplicate our logic in different lifecycle methods to implement our “effect logic.”

Class components do not offer an elegant solutionHOC and friends are not an elegant solution. to sharing logic between components — React Hooks, on the other hand, give us the ability to build custom hooks, which are a much simpler solution.

The list goes on and on. In a nutshell, function components with hooks are much more “in the spirit of React" – they make sharing and reusing components much simpler and easier.

Fetching data with a class component

When working with regular class components in React, we make use of lifecycle methods to fetch data from a server and display it with no problems.

Let’s take a look at a simple example:

class App extends Component {

     this.state = {
          data: []
     }
    componentDidMount() {
        fetch("/api/data").then(
            res => this.setState({...this.state, data: res.data})
        )
    }
    render() {
        return (
            <>
                {this.state.data.map( d => <div>{d}</div>)}
            </>
        )
    }
}

Once the component is mounted, it will fetch data and render it. Note that we didn’t place the fetch logic in the constructor, but instead, delegated it to the componentDidMount hook.

Network requests may take some time, so it’s better not to hold up your component from mounting. We resolve the Promise returned by the fetch(...) call and set the data state to the response data. This, in turn, will re-render the component (to display the new data in the component’s state).

From a class component to a function component

Let’s say we want to change our class component to a function component. How will we implement that so that the former behavior remains the same?

useState and useEffect

useState is a hook used to maintain local states in function components.

useEffect is used to execute functions after a component gets rendered (to “perform side effects”).

useEffect can be limited to cases where a selected set of values change. These values are referred to as dependencies. useEffects does the job of componentDidMount, componentDidUpdate, and componentWillUpdate combined.

These two hooks essentially give us all the utilities we previously got from class states and lifecycle methods. So, let’s refactor the App from a class component to a function component:

function App() {
    const [state, setState] = useState([])
    useEffect(() => {
        fetch("/api/data").then(
            res => setState(res.data)
        )
    })
    return (
        <>
            {state.map( d => <div>{d}</div>)}        
        </>
    )
}

The useState manages a local array state, state.

The useEffect hook will make a network request on component render. When that fetch resolves, it will set the response from the server to the local state using the setState function. This, in turn, will cause the component to render so as to update the DOM with the data.

Preventing endless callbacks using dependencies

However, we have a problem. useEffect runs when a component mounts and updates. In the above code, the useEffect hook will run when the App mounts and when the setState is called (after the fetch has been resolved); but, that’s not all. useEffect will get triggered again as a result of the component being rendered. As you’ve probably figured out yourself, this will resolve in endless callbacks.

As mentioned earlier, useEffect has a second param, the ‘dependencies’. These dependencies specify which cases useEffect should respond to a component being updated.

The dependencies are set as an array. The array will contain variables to check if they have changed since the last render. If any of them change, useEffect will run; if not, useEffect will not run.

useEffect(()=> {
    ...
}, [dep1, dep2])

An empty dependency array makes sure useEffect runs only once when the component is mounted.

function App() {
    const [state, setState] = useState([])
    useEffect(() => {
        fetch("/api/data").then(
            res => setState(res.data)
        )
    }, [])
    return (
        <>
            {state.map( d => <div>{d}</div>)}      
        </>
    )
}

Now, this functional component implementation is the same as our initial regular class implementation. Both will run on a mount to fetch data and then nothing on subsequent updates.

Memoizing using dependencies

Let’s look at a case where we can use dependencies to memoize useEffect.

Let’s say we have a component that fetches data from a query.

function App() {
    const [state, setState] = useState([])
    const [query, setQuery] = useState()
    useEffect(() => {
        fetch("/api/data?q=" + query).then(
            res => setState(res.data)
        )
    }, [query])
    function searchQuery(evt) {
        const value = evt.target.value
        setQuery(value)
    }
    return (
        <>
            {state.map( d => <div>{d}</div>)}<input type="text" placeholder="Type your query" onEnter={searchQuery} />  
        </>
    )
}

We have a query state to hold the search param that will be sent to the API.

We memoized the useEffect hook by passing the query state to the dependency array. This will make the useEffect hook load data for a query on an update/re-render, but only when the query has changed.

Without this memoization, useEffect will constantly load data from the endpoint, even when the query has not changed. This will cause unnecessary re-renders in the component.

So, we have a basic implementation of how we can fetch data in functional React components using hooks: useState and useEffect.

The useState hook is used to maintain the data response from the server in the component.

The useEffect hook is what we used to fetch data from the server (because it is a side-effect) and gives us lifecycle hooks only available to regular class components to fetch/update data on mounts on updates.

Error handling

Nothing comes without errors. We set up data fetching using hooks in the last section, which is awesome, but what happens if the fetch request returns with some errors? How does the App component respond?

We need to handle errors in the component’s data fetching.

Error handling in a class component

Let’s see how we can do it in a class component:

class App extends Component {
    constructor() {
        this.state = {
            data: [],
            hasError: false
        }
    }
    componentDidMount() {
        fetch("/api/data").then(
            res => this.setState({...this.state, data: res.data})
        ).catch(err => {
            this.setState({ hasError: true })
        })
    }
    render() {
        return (
            <>
                {this.state.hasError ? <div>Error occured fetching data</div> : (this.state.data.map( d => <div>{d}</div>))}
            </>
        )
    }
}

Now, we add a hasError to the local state with a default value of false. (Yes, it should be false, because at the initialization of the component, no data fetching has occurred yet).

In the render method, we use a ternary operator to check for the hasError flag in the component’s state. We also add a catch promise to the fetch call to set the hasError state to true when the data fetching fails.

Error handling in a function component

Let’s see the functional equivalent:

function App() {
    const [state, setState] = useState([])
    const [hasError, setHasError] = useState(false)
    useEffect(() => {
        fetch("/api/data").then(
            res => setState(res.data)
        ).catch(err => setHasError(true))
    }, [])
    return (
        <>
            {hasError? <div>Error occured.</div> : (state.map( d => <div>{d}</div>))}      
        </>
    )
}

Adding the Loading… indicator

Loading in a class component

Let’s see the implementation in a class component:

class App extends Component {
    constructor() {
        this.state = {
            data: [],
            hasError: false,
            loading: false
        }
    }
    componentDidMount() {
        this.setState({loading: true})
        fetch("/api/data").then(
            res => {
                this.setLoading({ loading: false})
                this.setState({...this.state, data: res.data})
                }
        ).catch(err => {
            this.setState({loading: false})
            this.setState({ hasError: true })
        })
    }
    render() {
        return (
            <>
                {
                    this.state.loading ? <div>loading...</div> : this.state.hasError ? <div>Error occured fetching data</div> : (this.state.data.map( d => <div>{d}</div>))}
            </>
        )
    }
}

We declare a state to hold the loading flag. Then, in the componentDidMount, it sets the loading flag to true. This will cause the component to re-render to display the “loading…”.

Loading in a function component

Let’s see the functional implementation:

function App() {
    const [state, setState] = useState([])
    const [hasError, setHasError] = useState(false)
    const {loading, setLoading} = useState(false)
    useEffect(() => {
        setLoading(true)
        fetch("/api/data").then(
            res => {
                setState(res.data);
                setLoading(false)}
        ).catch(err => {
            setHasError(true))
            setLoading(false)})
    }, [])
    return (
        <>
            {
                loading ? <div>Loading...</div> : hasError ? <div>Error occured.</div> : (state.map( d => <div>{d}</div>))
            }
        </>
    )
}

This will work the same way as the previous class component.

We added another state using the useState. This state will hold the loading flag.

It is initially set to false, so when the App mounts, the useEffect will set it to true (and a “Loading…” will appear). Then, after the data is fetched or an error occurs, the loading state is set to false, so the “Loading…” disappears, replaced by whatever result the Promise has returned.

Packaging all in a Node module

Let’s bind all that we have done into a Node module. We are going to make a custom hook that will be used to fetch data from an endpoint in functional components.

function useFetch(url, opts) {
    const [response, setResponse] = useState(null)
    const [loading, setLoading] = useState(false)
    const [hasError, setHasError] = useState(false)
    useEffect(() => {
        setLoading(true)
        fetch(url, opts)
            .then((res) => {
            setResponse(res.data)
            setLoading(false)
        })
            .catch(() => {
                setHasError(true)
                setLoading(false)
            })
    }, [ url ])
    return [ response, loading, hasError ]
}

We have it: useFetch is a custom hook to be used in functional components for data fetching. We combined every topic we treated into one single custom hook.

useFetch memoizes against the URL where the data will be fetched from, by passing the url param to the dependency array. useEffect will always run when a new URL is passed.

We can use the custom hook in our function components.

function App() {
    const [response, loading, hasError] = useFetch("api/data")
    return (
        <>
            {loading ? <div>Loading...</div> : (hasError ? <div>Error occured.</div> : (response.map(data => <div>{data}</div>)))}
        </>
    )
}

Conclusion

We have gone over how to use useState and useEffect hooks to fetch and maintain data from an API endpoint in functional components.

Attributions:
  1. undefined by undefined
Copyright ©2024 Educative, Inc. All rights reserved