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 anasync
function, anduseEffect()
can't beasync
, we need to define a separateasync
function to get the data and call it insideuseEffect()
.We can define the
asyncgetData()
function inside theuseEffect()
itself, but if we want to define it separately, we need to useuseCallBack()
. Otherwise, the function will be re-created on every component update and since it's the dependency of theuseEffect()
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.