skip to content

Lessons Learned from Building with React Server Components

January 13, 2024
6 minutes
Reactarchitecture

At the time of writing, we are a few weeks into 2024 and I think I'm past due to reflect on some learnings from the previous year. One of my goals was to start a blog. NextJS App Router was released, and I went to work creating a cutting-edge. I must have forgotten along the way that the point of creating a blog is to create posts. We’re in 2024 and I’ve spent most of the past year grokking at the NextJS App Router and React Server Components trying to figure out how to build with them and what it means for the future of React.

This stuff is complicated. I think as time goes on building more dynamic apps using these new features of RSCs will get easier, but I'm not ready to completely give up on SPAs for this new server-enabled world just yet. I can take some of the patterns and lessons learned from building with NextJS and RSCs and apply them to make better SPAs.

Component Composition or Props

There's a rule when building with the NextJS App Router that a client component cannot render a server component. Client components render first on the server, then again on the client. This is regular ol` SSR that we've been using in the pages router for years. We can't render our server component again on the client, because we're likely interacting with a database or performing some action that can only be done on the server.

page.tsx
import ClientComponent from './client-component'
 
export default function Page() {
  return <ClientComponent />
}
client-component.tsx
// This will throw an error
'use client'
import ServerComponent from './server-component'
 
export default function ClientComponent() {
  return (
    <div>
      <ServerComponent />
    </div>
  )
}
server-component.tsx
export default async function ServerComponent() {
  const data = await fetchData()
 
  return <div>{data}</div>
}

The NextJS docs suggest a different pattern, passing the server component as a child to the client component. This works because the rendering happens in page and client-component just decides where to put that rendered content.

page.tsx
import ClientComponent from './client-component'
import ServerComponent from './server-component'
 
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}
client-component.tsx
// This will throw an error
'use client'
 
export default function ClientComponent() {
  return (
    <div>
      {children}
    </div>
  )
}
server-component.tsx
export default async function ServerComponent() {
  const data = await fetchData()
 
  return <div>{data}</div>
}

Two other areas where I've found this composition pattern can be helpful is:

  • building flexible components
  • avoiding prop drilling

Building Flexible Components

Often we want to build a reusable component that provides some logic or layout, but is still allows the client to dictate what is rendered. I think an example is worth 1000 words here. Let's think about a List component.

list.tsx
export default function List({ items }: { items: string[] }) {
  return (
    <ul>
      {items.map(text => (<li>{text}</li>))}
    </ul>
  )
}

We pass in an array of strings that the List component iterates over and renders the items.

client.tsx
export default function Client() {
  const items = ['item 1', 'item 2', 'item 3'] 
 
  return <List items={items} />
}

If you've done this before, then you probably know where we're going next. Chances are you don't just want to render strings in your list. What if you're creating a nav and want to render anchor tags, or buttons, or maybe buttons with icons. Our List component not has control over what is rendered as well as how it's rendered. We can use composition to allow the client to specify the what while our component handles the how.

list.tsx
export default function List(({ children }: { children: react.ReactNode })) {
  return (
    <ul>
      {children}
    </ul>
  )
}
 
function ListItem({ children }: { children: react.ReactNode }) {
  return (
    <li>{children}</li>
  )
}
 
List.ListItem = ListItem

The client will have slightly more code, but they control what is rendered leading to a more flexible design.

client.tsx
export default function Client() {
  return (
    <List>
      <List.Item>Regular text</List.Item> 
      <List.Item><a href="#">an anchor tag</a></List.Item> 
      <List.Item>
        <button onClick={() => {}}>Click Me!</button>
      </List.Item> 
    </List>
  )
}

Avoiding Prop Drilling

Passing props to components an easy way to pass data from one component to another. Your first course of action when sharing state should be to try and lift it up to a common parent and pass it down. Prop drilling occurs when the shared parent is high up on your component tree and you end up with lots of components in the middle just passing state on to the next. React Context is a great way to get rid of prop drilling, but it's not the only one. We can also use composition to kind of "raise" those deep components higher up in the tree and reduce or eliminate the number of pass-through components.

function Parent() {
  const greeting = 'hello world'
 
  return (
    <>
      <h1>{greeting}</>
      <PassThrough message={greeting} />
    </>
  )
}
 
function PassThrough({ message }: { message: string }) {
  return (
    <div>
      <Child message={message} />
    </div>
  )
}
 
function Child({ message }: { message: string }) {
  return <div>{data}</div>
}

The only reason why PassThrough needs to know about the message prop is to pass it on the Child. We can use composition to remove the need by instead making PassThrough accept a children prop rather than directly rendering Child.

function Parent() {
  const greeting = 'hello world'
 
  return (
    <PassThrough>
      <Child message={greeting} />
    </PassThrough>
  )
}
 
function PassThrough({ children }: { children: React.ReactNode }) {
  return (
    <div>
      {children}
    </div>
  )
}
 
function Child({ message }: { message: string }) {
  return <div>{message}</div>
}

These are the two areas I find myself reaching for component composition most often, but this is by no means an exhaustive list. Generally speaking, I've found myself reaching for composition more and more and I think it leads to more maintainable code bases. I also failed to mention passing components as props. Passing a component as a prop in a similar way that you would pass children is also composition. You can use this to pass multiple "children" to a component. Remember, a react component is just a function, you can pass it around like you would any function.

Lean More on Web Standards

Server components don't execute any JavaScript on the client, they only render on the server. This has some consequences that we usually take for granted in React:

  • we can't share state amongst all our components by holding it in a global store.
  • We can't use event handlers like onClick and onSubmit, those APIs aren't available to us on the server.

We can't use traditional state management libraries to share state between server and client components. The fact that these are react components really don't matter. What we really want to do is share state between the client and the server.

Lucky for us there are a couple ways to share state bewteen the client and server, and I think there's reason to use them rather than storing application state in memory. The first is the query string. The query string can be used to store extra information about a request such as any filters or sorting that should be done to the data in the UI. Storing this in the query string means we can initialize the page using that data and a specific state can be shared via a link. The second is cookies. Cookies outlive the life of a single request which make them great for saving user settings or other configuration.