Skip to content

Client Components

Let's suppose we want to add a new feature to our “Hit Counter” example: the ability to obscure and reveal it.

Before I show you how to do this, I'd encourage you to spend 5-10 minutes hacking on it. You can use the same “hit-counter” project (opens in new tab) we used in the previous exercise.

The project already has all the CSS you need. Your goal is to wind up with the following markup:

<!-- Censored -->
<button class="censored">
123
</button>
<!-- Not censored -->
<button>
123
</button>

The current number of hits should be wrapped in a <button>, and the "censored" class should be toggled on and off when the button is clicked, using a React state variable like isCensored.

This is a surprisingly tricky challenge. I don't expect you to be able to solve it. But I do think there's tremendous value in tinkering with this, to get a feel for the problem.

Once you've done some experimentation, watch this video to explore a couple of different solutions:

Video Summary

We start with a slightly-modified setup: I've extracted the file-system stuff into a new HitCounter component. It also returns a <button> with the .censored class applied:

function HitCounter() {
let { hits } = JSON.parse(
readFile(DATABASE_PATH)
);
hits += 1;
writeFile(
DATABASE_PATH,
JSON.stringify({ hits })
);
return (
<button className="censored">{hits}</button>
);
}

(Note: To keep the code snippets concise, I'm omitting everything besides the component definition. For this code to work, you'd also have to import the readFile/writeFile methods, define the DATABASE_PATH constant, and export the HitCounter component. Please keep this in mind for the rest of the snippets in this summary!)

In order to make this toggleable, we need to add some React state. This state variable will be used to conditionally apply the .censored class. An onClick handler will flip the state variable:

function HitCounter() {
const [isCensored, setIsCensored] = React.useState(false);
let { hits } = JSON.parse(
readFile(DATABASE_PATH)
);
hits += 1;
writeFile(
DATABASE_PATH,
JSON.stringify({ hits })
);
return (
<button
className={
isCensored ? 'censored' : undefined
}
onClick={() => {
setIsCensored(!isCensored);
}}
>
{hits}
</button>
);
}

When we do this, we get an error:

useState only works in Client Components.
Add the "use client" directive at the top of the file to use it.

Listening to the error message, I added "use client"; to the top of the file… but then I get an even weirder error:

Failed to Compile.
Module not found: Can't resolve 'fs'

The "use client" directive makes this code run on the user's device. The problem is that our readFile and writeFile methods use the fs Node.js module, and this module doesn't work on the front-end.

In order to solve this problem, we need two components:

  • A Client Component that manages the state.
  • A Server Component that interacts with the file system.

My initial attempt was to lift the state up, to the page component, and to pass it down through props:

"use client";
function Home() {
const [isCensored, setIsCensored] = React.useState(false);
return (
<main>
<h1>Welcome!</h1>
<p>
You are visitor number
<HitCounter
isCensored={isCensored}
setIsCensored={setIsCensored}
/>
.
</p>
</main>
);
}

Unfortunately, this doesn't solve our problem. We still get the same error.

Here's the deal: Client Components can't render Server Components.

In this new version of the code, Home is a Client Component, which means it re-renders whenever its state changes. And when Home re-renders, it'll force HitCounter to re-render as well!

The whole point of Server Components is that they can't re-render. The code never ships to the client. They generate an immutable chunk of DOM.

The React core team spent years working on Server Components, and they wrestled with this situation. They could have set it up so that this sort of thing was possible. Here's what the hypothetical flow would be:

  1. The Client Component re-renders
  2. A network request is fired off to the server, with the new values for all props (isCensored={true})
  3. The server recalculates the new DOM markup (<button className="censored">), and sends it to the client
  4. On the client, React reconciles the change and adds the class to the button

The trouble with this approach is that network requests are slow. It could take several seconds for the re-render to complete, which is way too long. We'd need to come up with a loading state. 😬

And so, the React team decided against doing this. Instead, they made a rule:

When a Server Component is rendered by a Client Component, it becomes a Client Component.

Even though our HitCounter component doesn't have the "use client" directive, it winds up being included in our JS bundle anyway, so that the re-render can be processed on the client. And that leads to the same fs module issue.

There are two ways to solve this problem.

1. Pushing state to leaf nodes.

One option is to “flip” the structure so that the top-level page component is a Server Component, and our HitCounter component becomes the Client Component:

// Home.js
function Home() {
let { hits } = JSON.parse(
readFile(DATABASE_PATH)
);
hits += 1;
writeFile(
DATABASE_PATH,
JSON.stringify({ hits })
);
return (
<main>
<h1>Welcome!</h1>
<p>
You are visitor number{' '}
<HitCounter hits={hits} />.
</p>
</main>
);
}
// HitCounter.js
'use client';
function HitCounter({ hits }) {
const [isCensored, setIsCensored] =
React.useState(false);
return (
<button
className={
isCensored ? 'censored' : undefined
}
onClick={() => {
setIsCensored(!isCensored);
}}
>
{hits}
</button>
);
}

In this strategy, we do all of our server-specific work high up in the React tree, and push the state down as far as we can.

This works, but honestly, I don't love it. I really want to move the database stuff into the HitCounter component, to colocate the component with its data requirements! This sort of pattern is what attracted me to React Server Components in the first place!

Fortunately, there's another strategy we can use, but it's a bit of a brain-twister.

2: Leveraging slots

First, we'll create a new Client Component called Censored:

'use client';
function Censored({ children }) {
const [isCensored, setIsCensored] =
React.useState(false);
return (
<button
className={
isCensored ? 'censored' : undefined
}
onClick={() => {
setIsCensored(!isCensored);
}}
>
{children}
</button>
);
}

This will be the only Client Component we have, in the entire project.

HitCounter will become a Server Component which manages the file-system stuff:

function HitCounter() {
let { hits } = JSON.parse(
readFile(DATABASE_PATH)
);
hits += 1;
writeFile(
DATABASE_PATH,
JSON.stringify({ hits })
);
return hits;
}

And our Home component will combine them in a very specific way:

function Home() {
return (
<main>
<h1>Welcome!</h1>
<p>
You are visitor number{' '}
<Censored>
<HitCounter />
</Censored>
.
</p>
</main>
);
}

At first glance, this might not seem like it solves the problem: <HitCounter> is a child of <Censored>! So, won't it be forced into being a Client Component?

There's a super-important subtlety here: It's not the parent/child relationship that matters, it's the owner/ownee relationship.

Our top-level Home component is a Server Component, and it "owns" both the Censored and HitCounter components. This is the relationship that matters!

When the server renders this component, it comes up with immutable output for each of the Server Components. HitCounter produces a number (eg. 42), and that number is immutable: <HitCounter> will always produce 42, no matter what other changes happen on the client.

That value, 42, is then passed through children to Censored. Censored is a Client Component, and so it'll re-render whenever its state changes. But the children prop never changes: it's always 42.

In React, the "owner" is the component that renders a particular element. It decides what props to pass. And when the owner component re-renders, those props might've changed, and so the “ownee” component needs to re-render as well.

It takes a while for this stuff to fully "click", so please don't worry if your head is spinning!

This lesson builds on the “Parents vs. Owners” distinction we learned about in the last module. I suggest reviewing this lesson if you're feeling a little lost. ❤️

You can view my final solution on Github: