Skip to main content

Component Design with RSC

We have seen some of how RSCs work - RSC Intro, but what does this mean, as a developer, for how to best structure Apps and components?

Standards

For High Velocity, the implications of RSC have caused us to adopt this standard for how components are designed: Keep what doesn't need to be re-rendered on the client in server components. Use client components only when interactivity in the browser is needed

How?

This standard seems pretty straightforward, but there are some pitfalls to be avoided. We can illustrate these by working through an example.

Product Tile Component

Suppose we are building a product tile component. Given the goals stated above, this is going to start as a server component. We'd like to be able to drop this component anywhere in our app and just pass it a product ID.

Initial Component

components/product-tile.tsx
import {
ProductTileContainer,
ProductImages,
ProductImage,
ProductName,
ProductPrice
} from '@/ui';
import { getProductByID } from './data';

export interface ProductTileProps {
productID: string
}

export async function ProductTile(props: ProductTileProps) {
const product = await getProductByID(props.productID);

return (
<ProductTileContainer>
<ProductImages>
{product.images.map((image) => (
<ProductImage key={image.id} src={image.src} />
))}
</ProductImages>
<ProductName>{product.name}</ProductName>
<ProductPrice>{product.price}</ProductPrice>
</ProductTileContainer>
)
}

This all looks well and good. This is a server component and we can drop it in anywhere. It will fetch its own data and everything. No getStaticProps or getServerSideProps needed!

We could add product tiles to a carousel on our Home Page pretty easily:

app/page.tsx
import { Container, Carousel, CarouselItem } from 'ui';
import { ProductTile } from 'components';

export default async function HomePageRoute() {
return (
<Container>
<Carousel>
<CarouselItem>
<ProductTile id='S10001' />
</CarouselItem>
<CarouselItem>
<ProductTile id='S10002' />
</CarouselItem>
<CarouselItem>
<ProductTile id='S10003' />
</CarouselItem>
</Carousel>
</Container>
);
}

That looks super clean!

Of course we aren't done yet. Say we want to shoppers to add to their cart directly from the carousel.

Adding Interactivity.

Thankfully, we have a custom hook available in our codebase that handles adding to cart. Let's use that:

hooks/useAddtoCart.tsx
'use client';

export function useAddToCart(id: string) {
// ... fetch implementation here

return {
isLoading,
addToCart
}
}

components/product-tile.tsx
import {
ProductTileContainer,
ProductImages,
ProductImage,
ProductName,
ProductPrice,
Button
} from 'ui';
import { getProductByID } from './data';
import { useAddToCart } from '@/hooks';

export interface ProductTileProps {
productID: string
}

export async function ProductTile(props: ProductTileProps) {
const product = await getProductByID(props.productID);
const { addToCart, isLoading } = useAddToCart(product.id);


return (
<ProductTileContainer>
<ProductImages>
{product.images.map((image) => (
<ProductImage key={image.id} src={image.src} />
))}
</ProductImages>
<ProductName>{product.name}</ProductName>
<ProductPrice>{product.price}</ProductPrice>
<Button isLoading={isLoading} onClick={async () => {
await addToCart();
}}>
Add to Cart
</Button>

</ProductTileContainer>
)
}

Not too bad, but when we run it, there's a problem:

Next.js Error

The error message isn't all that clear. Nonetheless, the error is from trying to invoke a hook intended for client side use ("use client") in a server component.

The potential pitfall for component design lies in how to fix this. A perhaps intuitive approach might be to just make the ProductTile component itself a client component. We'd have to refactor a couple of files:

  1. Add "use client"; to the top of the product-tile.tsx file.
  2. Update the ProductTile component to accept the product object as a prop instead of the ID since we can't use async/await for server side rendering in client components.
  3. Update the home page route to fetch the products and pass them to the product tile.

This refactor might look like this:

app/page.tsx
import { Container, Carousel, CarouselItem } from 'ui';
import { ProductTile } from 'components';
import { getProductByID } from 'components/data';

export default async function HomePageRoute() {
const products = await Promise.all([
getProductByID('S10001'),
getProductByID('S10002'),
getProductByID('S10002'),
]);

return (
<Container>
<Carousel>
{products.map(product => (
<CarouselItem>
<ProductTile key={product.id} product={product} />
</CarouselItem>
))}
</Carousel>
</Container>
);
}
components/product-tile.tsx
"use client";

import {
ProductTileContainer,
ProductImages,
ProductImage,
ProductName,
ProductPrice,
Button
} from 'ui';
import { useAddToCart } from '@/hooks';

export interface ProductTileProps {
product: ProductModel
}

export function ProductTile(props: ProductTileProps) {
const { product } = props;
const { addToCart, isLoading } = useAddToCart(product.id);

return (
<ProductTileContainer>
<ProductImages>
{product.images.map((image) => (
<ProductImage key={image.id} src={image.src} />
))}
</ProductImages>
<ProductName>{product.name}</ProductName>
<ProductPrice>{product.price}</ProductPrice>
<Button isLoading={isLoading} onClick={async () => {
await addToCart();
}}>
Add to Cart
</Button>

</ProductTileContainer>
)
}

This will work...

But what tradeoffs are we making?

  1. We now have to fetch the data for the component outside of the component itself - in the home page route in this case. We've clearly lost some composibility.
  2. When props are passed from a server component to a client component, they are serialized - think how getStaticProps passes props to the react app in pages router. So the product object is now going to be in our bundle.
  3. The product tile component now will be hydrated/re-rendered in the browser.
  4. Now we couldn't call other server components from this product tile either. It's turtles (client components) all the way down.

For this particular case, these tradeoffs probably aren't a big deal. But if all of your components are designed this way, it's a recipe for a really big javscript bundle. This means a delayed Time to Interactive (TTI), slugish user interactions, and lower conversion rates.

Is there a better way? Yes, actually. Push client components to the "leaves" of your App. Server components are branches, client components are the leaves.

We can go back to our original home page:

app/page.tsx
import { Container, Carousel, CarouselItem } from 'ui';
import { ProductTile } from 'components';

export default async function HomePageRoute() {
return (
<Container>
<Carousel>
<CarouselItem>
<ProductTile id='S10001' />
</CarouselItem>
<CarouselItem>
<ProductTile id='S10002' />
</CarouselItem>
<CarouselItem>
<ProductTile id='S10003' />
</CarouselItem>
</Carousel>
</Container>
);
}

Create a client component for the add to cart button:

components/add-to-cart-button.tsx
"use client";

import { Button } from 'ui';
import { useAddToCart } from '@/hooks';

export interface AddToCartButtonProps extends React.PropsWithChildren {
id: string;
}

export async function AddToCartButton(props: AddToCartButtonProps) {
const { addToCart, isLoading } = useAddToCart(props.id);

return (
<Button isLoading={isLoading} onClick={async () => {
await addToCart();
}}>
{props.children}
</Button>
)
}

Now our Product Tile is a server component again!

components/product-tile.tsx
import {
ProductTileContainer,
ProductImages,
ProductImage,
ProductName,
ProductPrice
} from 'ui';
import { AddToCartButton } from 'components/add-to-cart-button';

export interface ProductTileProps {
productID: string
}

export async function ProductTile(props: ProductTileProps) {
const product = await getProductByID(props.productID);

return (
<ProductTileContainer>
<ProductImages>
{product.images.map((image) => (
<ProductImage key={image.id} src={image.src} />
))}
</ProductImages>
<ProductName>{product.name}</ProductName>
<ProductPrice>{product.price}</ProductPrice>
<ProductAddToCartButton>Add to Cart</ProductAddToCartButton>
</ProductTileContainer>
)
}

And we've reversed the tradeoffs made above.

We've restored composability and haven't added much at all to the the client bundle. All without sacrificing any interactivity.