Joonas' blog

Bringing <Button> to the React Server Component world

published

The humble reusable <Button> component is a fairly understood piece of design system fodder. You have a <Button> that essentially wraps a <button> (or a <a> type link) in some fancy custom classes and passes other props to the underlying element.

In this article I talk about how the introduction of React Server Components (RSC) changes the equation.

No-parameter onClick

Most <Button> components work by passing the onClick event directly to the underlying DOM element. This has one problem in RSC world: the actual PointerEvent event being passed to the event listener.

I’ll demonstrate with a server component example:

let counter = 0;
export default function MyPage() {
async function increment() {
"use server";
counter++;
}
async function decrement() {
"use server";
counter--;
}
return (
<div>
This is a cool page with a serverside counter: {counter}.
You can <span onClick={increment}>increment</span> or
<span onClick={decrement}>decrement</span> me.
</div>
)
}

This is very sleek and clean. It will also crash and burn because of this error: Error: Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported.. Everytime you press on the (in|de)crement spans, React tries to call the server action with the PointerEvent and it won’t work because of the non-serializable event object.

The cleanest method to solve this I have personally found is to have your own custom <Button> component not pass the event to onClick and use that for similar situations. You can even have an alternative event listener like onSimpleClick (with a hopefully better name) that specifically does not receive the event object.

Support asynchronous onClick

This is an extension on the above; since we’re messing with the built-in onClick anyway, why not make it a bit more feature-rich and make it asynchronous, i.e. onClick: () => Promise<void>. This looks a bit useless at first, until you realize that it can be used to automatically indicate the pending state of whatever is being asynchronously done in the event listener itself.

I’ve used this for showing pending state for async server actions, normal fetches, database update operations, and dialog popups among others. Promises are so surprisingly widely used that this ends up being even more useful in practice than you’d think.

And it doesn’t stop there: in React 19 we can get the loading state directly.

Check for loading state directly

The next react-dom v19 features a very useful hook useFormStatus. We can use this to indicate the loading status of a parent form. This doesn’t seem too cool, but it can be used to implement fully server component forms that still indicate interaction status to the user.

export default function MyPage() {
async function submit(fd: FormData) {
"use server";
// some artificial delay to let our shiny loading state show
await new Promise((r) => setTimeout(r, 3000));
console.log("received", Object.fromEntries(fd.entries()));
}
return (
<form action={submit}>
name: <input name="name" />
<br />
<Button type="submit">submit</Button>
</form>
);
}

Indicate form submit success status

This is not something I’ve done directly, but is doable: indicate whether the action succeeded directly on the button. With the onClick proxy event listener you can handle the result value of the called action/promise directly.

Add confirm directly to the button

You can probably tell the theme of this article already: enable traditionally client-only UX features by centralizing all of it into a client component.

This extends to some features that I would normally suggest keeping far away from tiny atomic Button components, but can be extremely useful in the context of RSCs. One of the features is confirm popup.

By adding a confirm prop that prompts the user to confirm whatever action they were about to do directly to a <Button>, it makes it super easy to add destruction action buttons directly to the page from server component without having to reach for the client realm at all.

Add app-specific flags directly to the button

This extends similarly to other client-side hooks or checks you might have. For instance, if there is a client-side admin mode flag to control whether some admin-related functionality is shown or not, you can simply add a onlyInAdminMode prop to the <Button> and make sure it only appears in the right context.

Final Button implementation

Here’s a kitchen-sink type <Button> that supports mostly everything mentioned here.

"use client";
import { ComponentProps, useState } from "react";
import { useFormStatus } from "react-dom";
import { useAdminMode } from "./utils/useAdminMode";
type Props = Omit<ComponentProps<'button'>, 'onClick'> & {
onClick?: () => void;
confirm?: boolean;
};
export function Button(props: Props) {
const { pending } = useFormStatus();
const adminMode = useAdminMode();
const [pendingClick, setPendingClick] = useState(false);
const onClickProxy = async () => {
const onClick = props.onClick;
if (!onClick) {
return;
}
if (
props.confirm &&
!confirm("are you sure?"))
) {
return;
}
try {
setPendingClick(true);
await onClick();
} finally {
setPendingClick(false);
}
};
if (props.adminOnly && !adminMode) {
return null;
}
// show spinner if
const showSpinner =
// it's a submit button and parent form is submitting, or
(props.type === "submit" && pending) ||
// a server action (onClick) is being called, or
pendingClick ||
// Button user wants the spinner manually
props.loading;
return (
<button
{...props}
onClick={onClickProxy}
// TODO show spinner if showSpinner is true
/>
);
}

Conclusion

Thanks for reading. I’ve also written about a more general guide about Button components in design systems.