Why I’m excited about Server Actions

Published on

Note: this post is focused on React as that’s my framework of choice, I’m aware of other frameworks like SolidStart and Qwik having their own solutions to this problem; but I wanted to focus on the problem more than performing an ecosystem analysis.

React and Vercel just announced server actions, with Next.js being the benchmark implementation. They’ll probably be controversial because they’re a little bit magic, but they’re a solution to a problem that’s frustrated me for years.

I’ve been doing server-side rendering with React for almost as long as React’s been around, and whilst it’s absolutely gotten much easier, there’s always been a problem — forms.

To rewind a bit, this isn’t a React problem, it’s a component problem. It’s about the impedance mismatch between the nature of the page-centric world that URL routers live in, and the portability of components. If you have one form on a page, and that page is *about* that form (maybe it’s a page for creating a blog post, and the form is the blog post form), it makes perfect sense for the route handler (view in Django, controller in Rails, etc) to process that form. You end up with the following nice pattern:

  1. Form POSTs to the current URL

  2. URL handler validates the form

  3. If the form is valid, process the data, then redirect to an appropriate page

  4. If the form is invalid, return the normal response, but augmented with the form validation error messages to allow the user to try again

This pattern is called Post/Redirect/Get (PRG), or redirect-after-successful-POST, and it works really well, and doesn’t require any client-side JavaScript to implement it.

When you have multiple forms on a page, it gets a little trickier. You have to provide some additional metadata that signals which form was submitted, then you need to only process that one. It gets even trickier when you have forms that may appear on arbitrary pages, maybe you have a contact form in the footer of every page. In this scenario you might reach for some middleware to make it so that every route knows how to process this form if POSTed to, but then you have to do some work to make the rest of the request behave like a GET in the event of a validation error.

In the component world of React et al, we typically aim for component portability; by having to explicitly integrate with the routing layer to process writes, we undermine that goal. Frameworks like Remix make the problem smaller by giving us nested routes and allowing us to define form handlers (actions) at any level of the router hierarchy, but they don’t eliminate it. Then there are patterns like Kent C Dodds’ Full Stack Components which do their best to encapsulate everything in one module, but still force us to integrate with the router.

Going back a decade or so, there have been plenty of strictly server-side frameworks that solved this, often via some heinous abuse of URLs. But the solutions always required a little bit of magic — an abstraction over the basic request/response nature of the web.

React’s solution is similarly magic, and as such requires some framework and bundler integration, which is why Next.js is being used as the reference implementation. But the principle is simple, and not that different to prior art:

  1. You write a server action as your form handler, a function pragma (“use server”) is used to annotate it

  2. In a server component, you set the `action` prop of a form to that action function <form action={myHandler}>…</form>

  3. When rendering out the form from the server, React adds some extra metadata — hidden inputs, that include a unique ID representing the action, as well as any necessary scope data. The action prop gets rewritten to the current URL.

  4. When the form is submitted, some internal Next.js middleware looks for the form metadata and uses that to find the right action function to process the form data.

All of this, lets us use an API like this (adapted from the Next.js announcement post):

jsx
async function create(formData) {
  'use server';
  const post = await db.post.insert({
    title: formData.get('title'),
    content: formData.get('content'),
  });
  redirect(`/blog/${post.slug}`);
}

export default function CreateBlogPostForm() {
  return (
    <form action={create}>
      <input type="text" name="title" />
      <textarea name="content" />
      <button type="submit">Submit</button>
    </form>
  );
}

This is pretty much the DX I’ve been chasing since 2014, and in truth it’s the primary reason why I wasn’t overly enthusiastic about Remix when it was first announced — it struck me as a beautifully considered API, but not the step forward in DX I’d been waiting for. Magic can be bad, but sometimes it’s worth it. I don’t know if history will show server actions to have been a good direction to take, but I’m really glad it’s being tried.