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
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.
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).
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
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.
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.
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.
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.
class
componentLet’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.
function
componentLet’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>))}
</>
)
}
class
componentLet’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…”.
function
componentLet’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.
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>)))}
</>
)
}
We have gone over how to use useState
and useEffect
hooks to fetch and maintain data from an API endpoint in functional components.