Fetching data with useEffect in React without libraries
This is an example in which we use React.js and make use of useEffect
Hook to fetch data from an external source in the client, which can be a very common use case for many applications.
This is a very common pattern that is taught but doing it properly involves a lot of planning and careful foresight. Needless to say there are a lot of pitfalls in implementing a useFecth()
yourself which is why we should always use third-party libraries that already take care of these possible issues. That being said, we will explore those pitfalls here so we can learn from them.
Let's start with the most vanilla version of this:
import { useState, useEffect } from "react"; export function FetchingWithUseEffect() { let [data, setData] = useState<{ userId: number; id: number; title: string; body: string; } | null>(null); useEffect(() => { fetch("https://jsonplaceholder.typicode.com/posts/5") .then((res) => res.json()) .then((data) => setData(data)); }, []); return ( <div className="stack"> <h3>{data?.title.toUpperCase()}</h3> <strong>Author: User # {data?.userId}</strong> <p>{data?.body}</p> </div> ); }
Ok this works. We are using the fetch API to call for resources and then using the Promise syntax to consume that JSON response and transform it into an Object so we can consequently then set our state using useState
with the data so we can finally properly render it in our JSX below.
Let's try changing it up so we use an async function. In order to do so we have to define a new function inside the effect and call it from there, we can't just turn the callback function into an async function because then it would return a promise and the only thing we can return from a useEffect
callback is a cleanup function.
useEffect(() => { async function getPost() { let res = await fetch( "https://jsonplaceholder.typicode.com/posts/5" ); let json = await res.json(); setData(json); } getPost(); }, []);
That's it! The data from our API is being fetched, set in state and being rendered! All is good, right? Well, hold on, what if our API suddenly went down. We wouldn't be rendering anything, and even if we had a spinner or some skeletons we need to handle a case in which our data never returns. This means we need to add some proper error handling in our component:
export function FetchingWithUseEffect() { let [data, setData] = useState<{ userId: number; id: number; title: string; body: string; } | null>(null); let [error, setError] = useState<Error | null | unknown>(null); useEffect(() => { async function getPost() { try { let res = await fetch( "https://jsonplaceholder.typicode.com/posts/5" ); if (res.status >= 500) { let text = res.statusText; setError(new Error(text)); } let json = await res.json(); if (json.error) { setError(new Error(json.error)); } else { setData(json); } } catch (e) { setError(e); } } getPost(); }, []); if (error) { return ( <div> <h1>Oopsie Error</h1> </div> ); } if (!data) { return ( <div> <h1>Data incoming... just one second pls</h1> </div> ); } return ( <div className="stack"> <h2>Fetching with useEffect</h2> <p>Example of how to properly fetch data with vanilla useEffect.</p> <hr /> <h3>{data?.title.toUpperCase()}</h3> <strong>Author: User # {data?.userId}</strong> <p>{data?.body}</p> </div> ); }
Here's a rundown of what we added:
- We added an
error
state with auseState
so we can store our error data - Inside our
useEffect()
- Added a
try/catch
block - Inside
try
- Still use
fetch
andawait
for the response - Check if the
res.status
property number is more than 500. If so it means we got a server error so we set ourerror
data to a new Error object with that message. - If that check passes it means we have a JSON response we can convert into an object. We
await
this as well. - Check if the
json
object has anerror
property. Here we are maybe checking for a custom error from the API, where the server handled the response but the API has custom error responses. - Else it means we have our data so we can
setData
and store the response in ourdata
- Still use
- In our
catch
block- We just set the
error
object to ourerror
state.
- We just set the
- We call our
getPost()
async function for all of the above to take place.
- Added a
- In our function component we:
- Check if
error
is non-null
. If so we must return some UI for handling that case where we had an actual error from the server or the API. - Check if
data
isnull
. If so it means thedata
state has not yet been set but its about to be set (because theerror
check passed we know data is incoming). Here is were we would put a spinner in place. - Finally we render what we had before given all the possible error handling we are adding.
- Check if
Ok. A bit contrived small example but it works!
Okay. Let's think about a way to add more functionality to this in a way that we can break it!
Let's add a <number>
input element that will let us choose the post we want to read. This means we will create a postId
state in which we hold the postId number so we can then conditionally fetch that post. Because now we are calling useEffect
based on some state, this means we have to add this variable to our dependency array:
export function FetchingWithUseEffect() { let [data, setData] = useState<{ userId: number; id: number; title: string; body: string; } | null>(null); let [error, setError] = useState<Error | null | unknown>(null); let [postId, setPostId] = useState<string | null>("5"); useEffect(() => { async function getPost() { try { let res = await fetch( `https://jsonplaceholder.typicode.com/posts/${postId}` ); if (res.status >= 500) { let text = res.statusText; setError(new Error(text)); } let json = await res.json(); if (json.error) { setError(new Error(json.error)); } else { setData(json); } } catch (e) { setError(e); } } getPost(); }, [postId]); if (error) { return ( <div> <h1>Oopsie Error</h1> </div> ); } if (!data) { return ( <div> <h1>Data incoming... just one second pls</h1> </div> ); } return ( <div className="stack"> <h2>Fetching with useEffect</h2> <p>Example of how to properly fetch data with vanilla useEffect.</p> <h2>Choose your post!</h2> <form> <input defaultValue={"5"} type="number" name="postID" onChange={(e) => setPostId(e.target.value)} /> </form> <hr /> <h3>{data?.title.toUpperCase()}</h3> <strong>Author: User # {data?.userId}</strong> <p>{data?.body}</p> </div> ); }
Great, we added more interactivity and our useEffect
works!
Let's keep handling edge cases. What if our users change the post they want to read, regret it and instantly click back? Our UI would flicker because once they clicked React has no way of knowing they regretted that decision so the fetch request is still sent out. Let's handle this with the handy Abort Controller API the web platform offers.
useEffect(() => { let controller = new AbortController(); async function getPost() { try { let res = await fetch( `https://jsonplaceholder.typicode.com/posts/${postId}`, { signal: controller.signal } ); if (!controller.signal.aborted) { return; } if (res.status >= 500) { let text = res.statusText; setError(new Error(text)); } let json = await res.json(); if (json.error) { setError(new Error(json.error)); } else { setData(json); } } catch (e) { if (e.name !== "AbortError") { setError(e); } } } getPost(); return () => { controller.abort(); } }, [postId]);
Let's take a look at what's happening here:
- First we create a new instance of the
AbortController
class in our hook - We then pass the
AbortSignal
object from the controller as an option in ourfetch
call.- This associates the signal and controller with this specific fetch request, which consequently allows us to abort by calling the
AbortController.abort()
method
- This associates the signal and controller with this specific fetch request, which consequently allows us to abort by calling the
- Next, we check whether the signal has been aborted before continuing. If the signal has not been aborted, i.e.
aborted
evaluates to false, we return early- I believe this piece of logic helps to dedupe requests, but we need to test it to see what it actually does
- We then catch any error that is not produced from a cancellation
- Finally and most important we return a cleanup function which calls the
abort()
method on the controller.- This ensures that any pending fetch request is cancelled when the component is unmounted or when the
postId
value changes
- This ensures that any pending fetch request is cancelled when the component is unmounted or when the
Let's add another common major issue that presents itself in React apps : What if I have another component in my app that can mutate any of the posts I'm fetching? How can I keep those mutations in sync with what I'm fetching and rendering. That is, how do I validate that the post I'm currently fetching is the latest and greatest, and not stale. A.K.A. Cache invalidation.
Well that opens a whole other can of worms, and at this point it is best for us to use a package that can handle all of this for us in a tight and crisp API. That's it for this example, but I invite you to look at using React Query for actually managing server-side state in your client-side app, which is what we grossly tried doing here.