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 a useState so we can store our error data
  • Inside our useEffect()
    • Added a try/catch block
    • Inside try
      • Still use fetch and await 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 our error 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 an error 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 our data
    • In our catch block
      • We just set the error object to our error state.
    • We call our getPost() async function for all of the above to take place.
  • 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 is null. If so it means the data state has not yet been set but its about to be set (because the error 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.

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 our fetch call.
    • This associates the signal and controller with this specific fetch request, which consequently allows us to abort by calling the AbortController.abort() method
  • 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

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.