Skip to content

Why Server Functions Matter In A Server Component World

Posted on:January 18, 2025

So this new framework called Tanstack Start has recently come onto the React scene and challenged some of the existing players like Remix and Next. Interestingly it has brought over the concept of server functions from SolidStart. So you might ask yourself are these server components, server actions, or something entirely different?

Let’s start by clarifying that these are not server components. As of right now server functions are for data and not components (this could change as RSC gets integrated into Tanstack Start). They also aren’t quite server actions because those are meant for mutating data. Server functions have some overlap with actions, but the main difference is they can also be used to get data.

So do you need server components to get the benefits of the server in React? I’d argue not! Next has conflated a lot of these topics under RSC and its App Router when they are separate incrementally adoptable concepts. Things like nested routing, OOO streaming, colocated data fetching, and type-safety across the network are all not unique to RSCs. I plan to write a future article on this.

So what are some differences and tradeoffs between the two? Let’s dive in!

Differences

Client by default architecture, with opt in server

Server functions enable a client by default architecture which is familiar to most React devs already. Components generally run on the client and with SSR they run once on the server on initial load. After the initial load of the app it is completely client-side and only when it needs data from the server does it make an api request.

Now we used to make these api requests by hand using fetch. Server functions make these calls transparent over what is called an RPC.

So how is server components different? They are a server-centric approach that require a new architecture and paradigm. They handle SSR similarly in that they render client components on the server. Where things change is on future navigations. When you go to a new page, instead of requesting raw json from the server you request prerendered serialized jsx for the page. React then knows how to turn this into content on the client.

This is what is meant by a server-centric approach. All components are only run on the server unless you specify 'use client'. Every click is like a whole new page that gets patched into the current page. One benefit of this is that you make only one trip to the server and get all the data you need at once. No client-server waterfalls, but instead you have server waterfalls. Server waterfalls are better, but they are still waterfalls that you should deal with.

You have to decide for yourself which architecture you like better. Do you want to interweave client/server components explicitly or just reach into the server from an isomorphic app as needed in a familiar way.

Granular invalidation

With server functions you make multiple calls for just the little pieces of data you need. You can also invalidate just one piece and refetch it. Server components on the other hand require you to make requests for all data on the next page you want to go to. If you wonder why Next needs a 'use cache' directive this is why. With the server-centric approach you lean into server caches (which require infrastructure) compared to client caches in the server function approach.

For example if you have two components called HomePage and AboutPage and you want to navigate between them it will make a second getUser call.

// page.tsx
export async function HomePage() {
  const user = await getUser();

  return (
    <>
      <h1>Home</h1>
      <p>{user.name}</p>
      <Link href="/">Go to about</Link>
    </>
  );
}

// about/page.tsx
export async function AboutPage() {
  const user = await getUser();

  return (
    <>
      <h1>About</h1>
      <p>{user.name}</p>
      <Link href="/">Go home</Link>
    </>
  );
}

To solve this problem in Next you would add a 'use cache' to the getUser function.

async function getUser() {
  "use cache";
  // return user
}

A client first approach would simply use something like Tanstack Query to cache the getUser result when navigating.

Hoisting data fetching

So server functions work best when you hoist your data fetching appropriately. This is done in Tanstack Router with its loaders, or in SolidStart with its preload function. If you don’t do this it can lead to waterfalls.

It is sometimes said the RSC solves all waterfall problems which isn’t exactly accurate. RSC advocates like to say that you don’t need to hoist data fetching in RSC, but I will try and convince you otherwise.

RSC allows you to await in async components, which naturally can lead to blocking its children. Siblings are fetched in parallel which is great, but children are not. In that case you still want to hoist out the fetching and then pass the promises down to where they are used.

function HomePage() {
  return (
    <>
      <h1>Home</h1>
      <Suspense fallback="Loading...">
        <Header />
      </Suspense>
    </>
  );
}

async function Header() {
  const user = await getUser();
  return (
    <>
      <h1>User</h1>
      {user.name}
      {/* The below component won't even start fetching
       until the above user is fetched */}
      <OnlineUsers />
    </>
  );
}

async function OnlineUsers() {
  const onlineUsers = await getOnlineUsers();
  return <>{JSON.stringify(onlineUsers)}</>;
}

As you can see above the <Header/> component will actually block the getOnlineUsers call until it gets the unrelated getUser call.

To fix this you would hoist the getOnlineUsers call as follows:

function HomePage() {
  return (
    <>
      <h1>Home</h1>
      <Suspense fallback="Loading...">
        <Header />
      </Suspense>
    </>
  );
}

async function Header() {
  const [user, onlineUsers] = await Promise.all([getUser(), getOnlineUsers()]);

  return (
    <>
      <h1>User</h1>
      {user.name}
      <OnlineUsers onlineUsers={onlineUsers} />
    </>
  );
}

function OnlineUsers(props) {
  return <>{JSON.stringify(props.onlineUsers)}</>;
}

Now wait! Even this isn’t ideal since if you have another unrelated component below <OnlineUsers/> it will block. So instead of this you can pass the promise down.

function HomePage() {
  const onlineUsersPromise = getOnlineUsers();
  const userPromise = getUser();

  return (
    <>
      <h1>Home</h1>
      <Suspense fallback="Loading...">
        <Header
          onlineUsersPromise={onlineUsersPromise}
          userPromise={userPromise}
        />
      </Suspense>
    </>
  );
}

async function Header(props) {
  return (
    <>
      <User userPromise={props.userPromise} />
      <OnlineUsers onlineUsersPromise={props.onlineUsersPromise} />
    </>
  );
}

async function User(props) {
  const user = await props.userPromise;

  return (
    <>
      <h1>User</h1>
      {user.name}
    </>
  );
}

async function OnlineUsers(props) {
  const onlineUsers = await props.onlineUsersPromise;

  return <>{JSON.stringify(onlineUsers)}</>;
}

A couple things to note here which make things things clunky.

So how does something like Tanstack Start differ?

export const Route = createFileRoute("/")({
  component: HomePage,
  loader: () => {
    // note this runs on client and on server
    return {
      onlineUsersPromise: getOnlineUsers(), // server function
      userPromise: getUser(), // server function
    };
  },
});

function HomePage() {
  const { onlineUsersPromise, userPromise } = Route.useLoaderData();

  return (
    <>
      <h1>Home</h1>
      <Suspense fallback="Loading...">
        <Header
          onlineUsersPromise={onlineUsersPromise}
          userPromise={userPromise}
        />
      </Suspense>
    </>
  );
}

function Header(props) {
  return (
    <>
      <User userPromise={props.userPromise} />
      <OnlineUsers onlineUsersPromise={props.onlineUsersPromise} />
    </>
  );
}

function User(props) {
  const user = use(props.userPromise);

  return (
    <>
      <h1>User</h1>
      {user.name}
    </>
  );
}

function OnlineUsers(props) {
  const onlineUsers = use(props.onlineUsersPromise);

  return <>{JSON.stringify(onlineUsers)}</>;
}

Bundle size vs payload size

So where do server components shine? Well it is if you need a smaller bundle size. It isn’t without its cost because you tend to pay for it with a larger payload size since it is serialized vdom. You can render big chunks of your UI on the server and avoid sending those bits to the client. A good example is a big syntax highlighting library.

This also depends on how interactive your site is. If you go back and forth to the server often you might want a reduced payload size, but in e-commerce applications you might want a smaller bundle size.

Conclusion

So I’d say the last point is the main one that matters. Are you optimizing for bundle size, or payload size? Other than that you can argue about the developer experience. Is the server component version better? I personally like the DX of server functions. It’s not quite as easy as just “await in an async component”, but when I can avoid waterfalls and blocking async I think it’s a win.

What are your thoughts?