DEV Community

Cover image for API Data Fetching in React / Next.js
Rashid Shamloo
Rashid Shamloo

Posted on • Updated on

API Data Fetching in React / Next.js

Remember the good old days when you could just use a UseEffect() hook, define an async function with useCallback() and call it inside useEffect(), use an AbortController() to abort the fetch when the component unmounts, use useState() to make the isLoading and isError states and manage them inside your try-catch, and call it a week?

Nowadays there are more ways to fetch your data and display it inside your component.

1. Fetching on the Client

Before the introduction of Server Components and Server Actions, all data fetching had to be done on the client. If you needed to hide sensitive information like API keys, you had to have another API between the client and the final API endpoint that fetched the data using the API key and sent it to the client.

1-1. useEffect() + useState()

This is the old way of doing it described at the start. the downside is that you have to write everything yourself (you have to add caching and re-fetching on top of everything mentioned already).

'use client';

import { useCallback, useEffect, useState } from 'react';

interface Data {
  name: string;
}

const MyComponent = () => {
  const [data, setData] = useState<Data>();
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  const getData = useCallback(
    async (signal: AbortSignal) => {
      setIsLoading(true);
      setIsError(false);
      try {
        const res = await fetch('url', { signal });
        const resJson = await res.json();
        setData(resJson);
      } catch (e) {
        setIsError(true);
        if (typeof e === "string") setError(e);
        else if (e instanceof Error) setError(e.message);
        else setError("Error");
      } finally {
        setIsLoading(false);
      }
    },
    []
  );

  useEffect(() => {
    const controller = new AbortController();
    getData(controller.signal);
    return () => controller.abort();
  }, [getData]);

  return (
    <div>{isLoading ? 'Loading...' : isError ? error : data && data.name}</div>
  );
};

export default MyComponent;

Enter fullscreen mode Exit fullscreen mode

ℹ️ Notes:

  • Since the code placed inside the main body of a functional component is executed on every update, we need to use useEffect() to fetch the data only when the component is mounted.
  • Because we can only use await inside an async function, and useEffect() can't be async, we need to define a separate async function to get the data and call it inside useEffect().
  • We can define the async getData() function inside the useEffect() itself, but if we want to define it separately, we need to use useCallBack(). Otherwise, the function will be re-created on every component update and since it's the dependency of the useEffect() it will cause it to re-run on every update as well.
  • Since a component may be mounted/unmounted many times, we need to abort the previous running fetch request when the component unmounts using an AbortController and returning a function that aborts the controller from useEffect().

1-2. Data Fetching Libraries

There are libraries like SWR, RTK Query, and React Query that simplify the data fetching process on the client and take care of the state, error handling, caching, and re-fetching for you.

RTK Query example:

'use client';

import { useGetDataQuery } from "path/to/apiSlice";

const MyComponent = () => {
  const { data, isLoading, isError, error } = useGetDataQuery();

  return (
    <div>{isLoading ? 'Loading...' : isError ? error : data && data.name}</div>
  );
};

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

2. Fetching on the server

Server Components and Server Actions allow data fetching and processing to be run on the server. The final result is sent to the client for display hiding all sensitive information and allowing for server caching of the data. This is the new paradigm in the React ecosystem.

2-1. Server Components

Server Components run on the server and unlike client components, can be async and directly (without the need for useEffect()) await and fetch the data and send the final result to the client. on top of the previously mentioned advantages of fetching on the server, this allows for patterns like Partial Prerendering where part of the page can be static and only the server components that need to fetch the data can be dynamically streamed.

interface Data {
  name: string;
}

const MyComponent = async () => {
  const res = await fetch("url");
  const data: Data = await res.json();

  return <div>{data.name}</div>;
};

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

Since the component itself is fetching the data and takes time to render, you can use Suspense in the parent to display a loading indicator.

import { Suspense } from "react";
import MyComponent from "./MyComponent";

const ParentComponent = () => {
  return (
    <Suspense fallback={"Loading..."}>
      <MyComponent />
    </Suspense>
  );
};

export default ParentComponent;
Enter fullscreen mode Exit fullscreen mode

For error handling, You can use an Error Boundary. By adding a Error.tsx
file to your app directory, Next.js automatically creates an error boundary, sets that component as the fallback, and lets you handle your errors easily.

ℹ️ Note: You can also handle the errors using a try-catch in your server component.

2-2. Server Actions

Server Actions are functions that run on the server and can be used to fetch data or perform any other task. The main benefit of Server Actions is that they can be called from client components. This gives you fine-grain control to choose which part of your code you want to run only on the server.

When running Server Actions in Server Components, you just need to add the 'use server' directive to the function declaration.

interface Data {
  name: string;
}

const getData = async () => {
  "use server";

  const res = await fetch("url");
  return await res.json();
};

const MyComponent = async () => {
  let data: Data = await getData();

  return <div>{data.name}</div>;
};

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

If you want to use a Server Action in a client component, you need to move the function to a separate file, add the 'use server' directive at the top, and import and use it in your client component.

Server Action (getDataOnServer.ts):

"use server";

export const getDataOnServer = async () => {
  const res = await fetch("url");
  return await res.json();
};
Enter fullscreen mode Exit fullscreen mode

Client Component:

import { getDataOnServer } from './getDataOnServer';
...
const data = await getDataOnServer();
Enter fullscreen mode Exit fullscreen mode

Let's modify our previous useEffect() + useState() client component to fetch the data using a Server Action instead. We have to move the try-catch error handling to the Server Action instead since the error will be thrown on the server, not on the client.

Server Action (getDataOnServer.ts):

"use server";

interface Data {
  name: string;
}

export const getDataOnServer = async () => {
  let data: Data | undefined = undefined;
  let isError = false;
  let error = "";

  try {
    const res = await fetch("url");
    data = await res.json();
  } catch (e) {
    isError = true;
    if (typeof e === "string") error = e;
    else if (e instanceof Error) error = e.message;
    else error = "Error";
  }

  return { data, isError, error };
};
Enter fullscreen mode Exit fullscreen mode

Client Component:

"use client";

import { getDataOnServer } from "./getDataOnServer";
import { useCallback, useEffect, useState } from "react";

interface Data {
  name: string;
}

const ClientMyComponent = () => {
  const [data, setData] = useState<Data>();
  const [error, setError] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  const getData = useCallback(async () => {
    setIsLoading(true);
    const { data, isError, error } = await getDataOnServer();
    setIsLoading(false);
    if (!isError) setData(data);
    else {
      setIsError(isError);
      setError(error);
    }
  }, []);

  useEffect(() => {
    getData();
  }, [getData]);

  return (
    <div>{isLoading ? "Loading..." : isError ? error : data && data.name}</div>
  );
};

export default ClientMyComponent;
Enter fullscreen mode Exit fullscreen mode

⚠️ Warning:

  • We can't pass objects like an AbortSignal to a Server Action so we can't abort the fetch request on component unmount.
  • Server Actions are usually used for data mutation, for fetching and displaying the data, Server Components are a better option.

ℹ️ Note: You can also use the Error Boundary (Error.tsx) approach for handling errors.


- Validation

In all the examples above we're blindly setting our data to the result of res.json() which is of type any. This is not a good practice especially if you're fetching data from a third-party API.

Before using the result of our fetch request, we need to verify that it is in the format we expect. To do that we can use a validation library like Zod.

We first make the schema for the data:

import { z } from "zod";

const dataSchema = z.object({ name: z.string() });
Enter fullscreen mode Exit fullscreen mode

Then we can use the parse() or safeParse() methods to validate our data:

...
const resJson = await res.json();

// will throw an error if parse() fails
const data = dataSchema.parse(resJson)

// will not throw an error
const parseResult = dataSchema.safeParse(resJson)
if (parseResult.success)
  data = parseResult.data;
else
  // handle error
Enter fullscreen mode Exit fullscreen mode

ℹ️ Note: Parsing the API response with Zod also throws away any extra JSON data that is not in your schema and only returns the needed data.

- API Routes

While you can use Next.js API Routes to fetch data on the server, it's not a separate way of doing it. they're just an API endpoint. you still have to make a fetch request and get the result from your API Route inside your component.


Because RSC (React Server Components) and Server Actions are a new addition, there are some compatibility issues with some packages and libraries but they are getting updated and new workarounds are being implemented as we move forward.

Top comments (12)

Collapse
 
lebinhan profile image
Lê Bình An

Though I have used the server side data fetching myself and really like it, I still think that it would be nicer to have code examples for each way of data fetching, then give your in-depth knowledge of each pros and cons, rather than just write down your opinion like this.
I’m looking forward for some extend version of this post with code examples so everyone can discuss together.

Collapse
 
rashidshamloo profile image
Rashid Shamloo

This post is just an overview of different data fetching methods and their pros and cons. Including code would make it unnecessarily long and you can make it as complicated or as simple as you want depending on how in-depth you want to go. from implementing all the functionalities using useEffect() + useState() to implementation details of data fetching libraries that each need a whole new article (eg. for RTK Query: setup, using together with a Redux store, excluding from redux-persist, useQuery, useLazyQuery, useMutation, tags, etc.). you can even introduce a state machine like XState. As for Server Components and Server Actions, they are just async functions that run on the server and you can do whatever you want in them (read/write to a database, fetch data from API, transform data, etc.). again there are implementation details, like configuring caching and revalidation for fetch requests, streaming using Suspense, optimistic updates using useOptimistic(), or even using libraries like next-safe-action.
And I've not even mentioned Next.js API routes since they're just another layer in the middle and depending on whether you want to give access to other apps and the complexity of your application you can opt to use them.

For in-depth implementation details, I suggest referring to each section's links and reading more about them.

Collapse
 
floony7_87 profile image
Fred Lunjevich

I agree. We're not looking for massive detail but even just a sketch of the new and old ways would have been useful (I came here looking for server action info). The title of the article suggests that there is a "how to" coming, that doesn't materialise, thus, the title could be updated to say it's an "overview" for clarity.

Collapse
 
rashidshamloo profile image
Rashid Shamloo

I have now added code examples and more explanations to each section. While it doesn't cover everything, hopefully, it'll provide some information about implementation details and common use cases.

Collapse
 
vbcd profile image
Stanislav Ø.

You don't need to include 'setData' in the dependency array of 'useCallback' - this is peculiar. React guarantees that state setters never change. There's no need for 'useCallback' there at all. You don't need to include 'getData' in the dependency array of 'useEffect' either. This code has a noticeable odor.

Collapse
 
rashidshamloo profile image
Rashid Shamloo

You are correct that state setters don't change and are not needed in the dependency array. I have removed setData from it.

About the useCallback(), other than the fact that not using it will result in the function unnecessarily being recreated on every render, generally, you don't fetch a static 'url' string and instead use a prop or state variable like userId, productId, etc. to fetch the data in which case you will need to use the useCallback hook. You can move the function definition inside the useEffect() and get rid of it but I personally prefer to have my functions separate.

More info: React Docs: Is it safe to omit functions from the list of dependencies?

Also, this is a good example of why you wouldn't want to manually write the data-fetching logic yourself and instead use a library. and why server components are a great addition because they allow you to fetch the data without using any hooks.

Note: I rarely set the dependency array manually and use ESLint to fill it instead (react-hooks/exhaustive-deps). It used to complain about any function/variable used in the hook that was not in the dependency array (I think even the state setters) but now that I checked again it doesn't do it anymore. so it could be the source of some of the confusion.

Note: React Forget will fix some of these problems by remembering/caching all functions by default.

Collapse
 
vbcd profile image
Stanislav Ø.

If you prefer to keep functions separate, you can simply declare them outside the scope of your component. Typically, request handling logic containing fetch or axios should reside in a separate module and be imported. This way, it won't be recreated on every render, and you won't need to use useCallback. However, there is no significant performance gain in avoiding small function recreation unless you are passing it as a prop to another component. Then, the fetching logic that contains state should reside in a separate hook that returns that state, which either success or error data. Including in the dependency array only parameters that clearly could change gives you a clearer mental model of how your code works. Adding instead the entire function to the deps and wrapping it in useCallback is awkward and mentioned as a 'last resort' even in those legacy React docs.

Anyway, the bottom line is - yes, there is little reason not to use a dedicated fetching library nowadays.

Thread Thread
 
rashidshamloo profile image
Rashid Shamloo

Since there's a need to manage the state in the function it can't be declared outside the component (you can move the fetch call to an outside function, but you'd still need to call that in your inner function and manage the state similar to what it is now)

I agree that if someone wants to manually code the fetch logic, it's better to make a custom useFetch() hook and use it instead for easier usage and cleaner code but custom hooks re-run at every re-render and the function will still be recreated on each render (unless useCallback() is used). plus with a custom hook, you'd be passing the url as a prop now (useFetch('url')) and the function has to be added to the dependency of useEffect() and as a result has to be wrapped in useCallback().

example hook:

import { useCallback, useEffect, useState } from 'react';

interface Data {
  name: string;
}

const useFetch = (url: string) => {
  const [data, setData] = useState<Data>();
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  const getData = useCallback(
    async (signal: AbortSignal) => {
      setIsLoading(true);
      setIsError(false);
      try {
        const res = await fetch(url, { signal });
        const resJson = await res.json();
        setData(resJson);
      } catch (e) {
        setIsError(true);
        if (typeof e === 'string') setError(e);
        else if (e instanceof Error) setError(e.message);
        else setError('Error');
      } finally {
        setIsLoading(false);
      }
    },
    [url]
  );

  useEffect(() => {
    const controller = new AbortController();
    getData(controller.signal);
    return () => controller.abort();
  }, [getData]);

  return { data, isLoading, isError, error };
};

export default useFetch;
Enter fullscreen mode Exit fullscreen mode

usage:

const { data, isLoading, isError, error } = useFetch('url');
Enter fullscreen mode Exit fullscreen mode

While as you said, the overhead for recreating a small function is not much, I prefer not to let that happen. my approach is that if the function is not using the state I define it outside the component (or move it to a util.ts file) and if I define a function inside the component, I always use useCallback() to memorize it.

Thread Thread
 
vbcd profile image
Stanislav Ø. • Edited

"Since there's a need to manage the state in the function it can't be declared outside the component" - It (and everything in the functional world) totally can, you just need to pass your own resolve / reject callbacks. And nothing will be recreated in "useFetch" hook if you declare it inside useEffect. Example of the proper useFetch hook - usehooks-ts.com/react-hook/use-fetch. Notice another important part there - only one state is possible, like "loading", "fetch", or "error" instead of isLoading/isError booleans gymnastics (see more kentcdodds.com/blog/stop-using-isl...)

Thread Thread
 
rashidshamloo profile image
Rashid Shamloo

It's technically possible (by passing all state setters (or dispatch if using useReducer()) to another function and controlling the component's state externally) but it will make reading the code harder than using a useCallback() hook and would only make sense if you'll be using that function in multiple different hooks/components.

StackOverflow: Pass a state setter as an argument to an async function

If you were to do that in the current hook (don't do it :)):

import { Dispatch, SetStateAction, useEffect, useState } from 'react';

interface Data {
  name: string;
}

const getData = async (
  url: string,
  signal: AbortSignal,
  setIsLoading: Dispatch<SetStateAction<boolean>>,
  setIsError: Dispatch<SetStateAction<boolean>>,
  setError: Dispatch<SetStateAction<string>>,
  setData: Dispatch<React.SetStateAction<Data | undefined>>
) => {
  setIsLoading(true);
  setIsError(false);
  try {
    const res = await fetch(url, { signal });
    const resJson = await res.json();
    setData(resJson);
  } catch (e) {
    setIsError(true);
    if (typeof e === 'string') setError(e);
    else if (e instanceof Error) setError(e.message);
    else setError('Error');
  } finally {
    setIsLoading(false);
  }
};

const useFetch = (url: string) => {
  const [data, setData] = useState<Data>();
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const controller = new AbortController();
    getData(
      url,
      controller.signal,
      setIsLoading,
      setIsError,
      setError,
      setData
    );
    return () => controller.abort();
  }, [url]);

  return { data, isLoading, isError, error };
};

export default useFetch;
Enter fullscreen mode Exit fullscreen mode

I think I have mentioned multiple times by now that declaring the function inside the useEffect() will allow you to drop the useCallback() and you would only need it if you define your functions outside the useEffect().

I've checked that useFetch() hook before, and it is indeed better. but I'm not trying to write a "proper" useFetch() hook and there are most likely more problems with things like error handling in my code. I'm just trying to demonstrate what fetching in a React component would look like without using a library like RTK Query, Server Components, or Server Actions. (I'm using the same state variable names as the ones returned by RTK Query's useQuery() hook)

As the useFetch() hook you linked shows, when managing multiple state variables, using the useReducer() hook is a better solution, and then as the article you linked mentions (and as I mentioned in one of my comments around a month ago) you will most likely want to use a state machine like XState instead.

Thread Thread
 
vbcd profile image
Stanislav Ø.

"If you were to do that in the current hook (don't do it :))" - sure, don't do it this way and this is not at all what I meant, it could be abstracted away with only 2 callbacks.

Thread Thread
 
rashidshamloo profile image
Rashid Shamloo

It may very well be my skill issue but I don't see how that would work. As callbacks, promises, and async/await work the same (throwing in an async function is reject and returning a value is resolve), and you still need to manage the state somewhere in your code. (unless you mean using the then().catch() syntax instead of async/await or something)