PostHole
Compose Login
You are browsing eu.zone1 in read-only mode. Log in to participate.
rss-bridge 2025-04-25T00:00:00+00:00

What Does "use client" Do?

Two worlds, two doors.


What Does "use client" Do?

April 25, 2025

Pay what you likeYouTube

React Server Components (in?)famously has no API surface. It’s an entire programming paradigm largely stemming from two directives:

  • 'use client'
  • 'use server'

I’d like to make a bold claim that their invention belongs in the same category as structured programming (if / while), first-class functions, and async/await. In other words, I expect them to survive past React and to become common sense.

The server needs to send code to the client (by sending a <script>). The client needs to talk back to the server (by doing a fetch). The 'use client' and 'use server' directives abstract over those, offering a first-class, typed, and statically analyzable way to pass control to a piece of your codebase on another computer:

  • 'use client' is a typed <script>.
  • 'use server' is a typed fetch().

Together, these directives let you express the client/server boundary within the module system. They let you model a client/server application as a single program spanning the two machines without losing sight of the reality of the network and serialization gap. That, in turn, allows seamless composition across the network.

Even if you never plan to use React Server Components, I think you should learn about these directives and how they work anyway. They’re not even about React.

They are about the module system.


'use server'

First, let’s look at 'use server'.

Suppose you’re writing a backend server that has some API routes:

async function likePost(postId) {
const userId = getCurrentUser();
await db.likes.create({ postId, userId });
const count = await db.likes.count({ where: { postId } });
return { likes: count };

async function unlikePost(postId) {
const userId = getCurrentUser();
await db.likes.destroy({ where: { postId, userId } });
const count = await db.likes.count({ where: { postId } });
return { likes: count };

app.post('/api/like', async (req, res) => {
const { postId } = req.body;
const json = await likePost(postId);
res.json(json);
});

app.post('/api/unlike', async (req, res) => {
const { postId } = req.body;
const json = await unlikePost(postId);
res.json(json);
});

Then you have some frontend code that calls these API routes:

document.getElementById('likeButton').onclick = async function() {
const postId = this.dataset.postId;
if (this.classList.contains('liked')) {
const response = await fetch('/api/unlike', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ postId })
});
const { likes } = await response.json();
this.classList.remove('liked');
this.textContent = likes + ' Likes';
} else {
const response = await fetch('/api/like', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ postId, userId })
});
const { likes } = await response.json();
this.classList.add('liked');
this.textContent = likes + ' Likes';
});

(For simplicity, this example doesn’t try to handle race conditions and errors.)

This code is all dandy and fine, but it is “stringly-typed”. What we’re trying to do is to call a function on another computer. However, since the backend and the frontend are two separate programs, we have no way to express that other than a fetch.

Now imagine we thought about the frontend and the backend as a single program split between two machines. How would we express the fact that a piece of code wants to call another piece of code? What is the most direct way to express that?

If we set aside our preconceived notions about how the backend and the frontend “should” be built for a moment, we can remember that all we’re really trying to say is that we want to call likePost and unlikePost from our frontend code:

import { likePost, unlikePost } from './backend'; // This doesn't work :(

document.getElementById('likeButton').onclick = async function() {
const postId = this.dataset.postId;
if (this.classList.contains('liked')) {
const { likes } = await unlikePost(postId);
this.classList.remove('liked');
this.textContent = likes + ' Likes';
} else {
const { likes } = await likePost(postId);
this.classList.add('liked');
this.textContent = likes + ' Likes';

The problem is, of course, likePost and unlikePost cannot actually execute on the frontend. We can’t literally import their implementations into the frontend. Importing the backend directly from the frontend is by definition meaningless.

However, suppose that there was a way to annotate the likePost and unlikePost functions as being exported from the server at the module level:

'use server'; // Mark all exports as "callable" from the frontend

export async function likePost(postId) {
const userId = getCurrentUser();
await db.likes.create({ postId, userId });
const count = await db.likes.count({ where: { postId } });
return { likes: count };

export async function unlikePost(postId) {
const userId = getCurrentUser();
await db.likes.destroy({ where: { postId, userId } });
const count = await db.likes.count({ where: { postId } });
return { likes: count };

We could then automate setting up the HTTP endpoints behind the scenes. And now that we have an opt-in syntax for exporting functions over the network, we could assign meaning to importing them from the frontend code—importing them could simply give us async functions that perform those HTTP calls:

import { likePost, unlikePost } from './backend';

document.getElementById('likeButton').onclick = async function() {
const postId = this.dataset.postId;
if (this.classList.contains('liked')) {
const { likes } = await unlikePost(postId); // HTTP call
this.classList.remove('liked');
this.textContent = likes + ' Likes';
} else {
const { likes } = await likePost(postId); // HTTP call
this.classList.add('liked');
this.textContent = likes + ' Likes';

That’s precisely what the 'use server' directive is.

This is not a new idea—RPC has been around for decades. This is just a specific flavor of RPC for client-server applications where the server code can designate some functions as “server exports” ('use server'). Importing likePost from the server code works the same as a normal import, but importing likePost from the client code gives you an async function that performs the HTTP call.

Have another look at this pair of files:

'use server'; // Mark all exports as "callable" from the frontend

export async function likePost(postId) {
const userId = getCurrentUser();
await db.likes.create({ postId, userId });
const count = await db.likes.count({ where: { postId } });
return { likes: count };

export async function unlikePost(postId) {
const userId = getCurrentUser();
await db.likes.destroy({ where: { postId, userId } });
const count = await db.likes.count({ where: { postId } });
return { likes: count };
import { likePost, unlikePost } from './backend';

document.getElementById('likeButton').onclick = async function() {
const postId = this.dataset.postId;
if (this.classList.contains('liked')) {
const { likes } = await unlikePost(postId); // HTTP call
this.classList.remove('liked');
this.textContent = likes + ' Likes';
} else {
const { likes } = await likePost(postId); // HTTP call
this.classList.add('liked');
this.textContent = likes + ' Likes';

You may have objections—yes, it doesn’t allow multiple consumers of the API (unless they’re within the same codebase); yes, it requires some thought as to versioning and deployment; yes, it is more implicit than writing a fetch.

[...]


Original source

Reply