API Data Fetching in React / Next.js

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;

ℹ️ 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 asyncgetData() 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;

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;

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;

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;

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();
};

Client Component:

import { getDataOnServer } from './getDataOnServer';
...
const data = await getDataOnServer();

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 };
};

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;

⚠️ 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() });

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

ℹ️ 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.