Silent Sabotage in React Server Components
December 22, 2023
I have started to write a bit more on web standards and web components but we still use Nextjs and in one place the app router for a project at work. I spent the better part of an afternoon figuring out an issue I was having with fetching data in a component in the app router.
The issue
The issue I am speaking of is how and where to fetch data.
I have a course on migrating from Nextjs pages router to the app router so I was pretty stumped with why this was an issue. But as most issues people have experienced with Server Components, the separation of concerns is not clear enough and the patterns and practices we have spent, for many of us, the majority of our careers working with are bleeding into each other.
In the course I built, we build up to Server Components. This means we move a normal component in the pages router to the app router and slap the "use client" directive at the top and continue doing our data fetch as we had been. Then we move our server code from an api route handler into the client component, remove the client fetching code, either with useSWR or react-query, remove the "use client" direct, and make our component async so we can run asynchronus code in the component.
However, migrating is rarely that straight forward and as you try to migrate quickly you will likely run into this data fetching issue I have outline below.
Typical data fetching patterns
In a typical React app or Nextjs page router application, you typically have api routes. So you would see something like this in a react app (please do not get caught up on the niceness of the code, I did in fact ask ChatGPT to write this code):
import React, { useState, useEffect } from "react";
function DataFetcher() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch("https://api.example.com/data")
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
})
.then((data) => {
setData(data);
setIsLoading(false);
})
.catch((error) => {
setError(error);
setIsLoading(false);
});
}, []); // Empty dependency array means this effect runs once after the initial render
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<h1>Data Fetched</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
export default DataFetcher;
This code is fetching from a server you have created or another api that you are using for data. So you might have made an express app where you have route handlers that fetch from a database and then in your React app, like above, you would fetch from that server you have written.
The same principle goes for the writing code in a Nextjs pages router application.
You can use the same code as above for a page or component but instead of having a server running and a client running, your pages router has the /api/data.ts
file in it where you are doing the data fetching and so on.
So a typical flow of the way we have been building applications for many years is this:
client app -> fetch from server -> server fetches data or uses an ORM or something to get data for that route -> client receives data.
The separation of concerns was always there in a good way. Model View Controller (MVC) patterns where maybe a bit too much of separating concerns but at least you knew where to look, even if it took you a while to get there.
So all of this is background for us as we come into this new React Server Components world.
How server components fetch data
I really need to work on a graphic but Server Components simply let you write React code on the server.
So imagine the express app that you have build being able to return React code along with the data. That seems nice in one sense. I can “just write react” and fetch data without all the noise of useEffects and what not.
However, if you try to write your express app code in a Server Component you will get some obscure or silent error. Lets look at why.
Express app
We are going to write this in a ‘one file’ example for demonstration purposes.
But imagine you have your typical React code like above, just shortened to show where we fetch data:
useEffect(() => {
fetch("https://api.example.com/data")
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
})
.then((data) => {
setData(data);
setIsLoading(false);
})
.catch((error) => {
setError(error);
setIsLoading(false);
});
}, []);
Now take that same example and we will put our express route handler for http://localhost:4000/data
there at a route handler called data:
//React app running on port 3000
useEffect(() => {
fetch('http://localhost:4000/data')
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
setData(data);
setIsLoading(false);
})
.catch(error => {
setError(error);
setIsLoading(false);
});
}, [])
//express app running on port 4000
app.get('/data', (req, res) => {
// Placeholder data
const placeholderData = await fetch(`/some-api-or-database`)
// Sending JSON response
res.json(placeholderData);
});
One is on the client and one is on the server. Now a ‘Server’ Component has been referred to as a ‘client for server side rendering’. I don’t know if that is the best phrase but I don’t have a better one. But it is the same concept as sending ‘server sent html’ in the response body from an express route handler.
In this case though you are writing composable react code. Again, that is nice in theory but this is where the confusion starts.
Here is a Server Component fetching data in a way many who come to Server Components from a traditional background would try and be utterly stumped by. Lets say you have a slow migration of your pages router app to the app router going on. You would think you can keep your route handler and then fetch it like normal, like so
Server Component
async function DataFetcher() {
const data = await fetch("https://api.example.com/data").then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
});
return (
<div>
<h1>Data Fetched</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
export default DataFetcher;
But alas, something goes awry.
Server components, like an express route handler, run ‘on the server’. Meaning if you fetch within a fetch, so say you fetch from a route handler within a server component, you are fetching too much.
I don’t even like how that sounds. But simply put it is like trying to fetch from your server while fetching from your server. So instead you need to take your server code and put it within the server component. So we will take our placeholder code from the express dummy route we made and put it within the Server Component
async function DataFetcher() {
const data = await fetch(`/some-api-or-database`);
return (
<div>
<h1>Data Fetched</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
export default DataFetcher;
Conclusion
It makes sense when you think about it but it is also a new patter that feels a bit like an anti-pattern to the way we have been doing things before. In Remix you have loaders and actions which by their names let you know that they run on the server. In SvelteKit you have separation by files using .server.js or .js which let you know where it is running. Server Components being the default for Nextjs app router apps is a big change and it is easy to forget that the “Server” in Server Components means ‘runs on the server’.
If you want to check out a framework I have been enjoying, the one this site is built with, check out enhance. Simple html, css, and progressive enhancement with JavaScript. Api routes are intuitive and fast. And custom elements are pretty great.