Server actions and javascript-free form submission
May 17, 2024
Recently, I’ve been working on a project to surface roles at interesting companies. I built it in Next.JS because it’s been a while since I had some hands-on time with the framework, and I wanted to see where things are, especially regarding performance and server-side rendering. I’m playing around with server actions, which enable a refreshing revisit to the days before AJAX was a thing: form submission even when Javascript is disabled (or more likely, when it fails).
The key that enables this is something called server actions alongside a distinction between server and client components. One important thing to understand is the “use client” and “use server” directives that are (sometimes) required when creating components in this old-is-new world of SSR. I didn’t quite have a grasp on this, so I went exploring to build out my understanding.
When building a site using the Next.JS app router, page.tsx
files are all server components by default. So I can have the following code in a page component and it works great:
return (
<form action={addCompany}>
<label htmlFor="companyName">Company Name</label>
<input
type="text"
name="companyName"
id="companyName"
placeholder="Enter company name"
required
/>
<input type="submit" />
</form>
);
There’s no state here, and addCompany
is a server action. Submission happens server-side with a document-level POST if JS is disabled, then the page refreshes. If JS is enabled, it makes a fetch POST request, and the page updates on the client side. This is a great example of progressive enhancement (or graceful degradation, depending on your perspective) for situations when JS is unavailable.
The problem I ran into from here is that I want to clear the form on submission. This actually happens if JS is disabled since a full page reload takes place. But as this conversation discusses, it’s not as obvious what to do when the view gets a client-side update.
Looking at what’s happening, I realized I needed to do some JS manipulation on this. Two approaches come to mind: useState
and useRef
. I played around with useRef
a bit to see if I could call a reset()
on the form, but I couldn’t find a solution that felt correct. I was trying to work on it from a native browser API perspective, but I was already inside React, so it was a tug-of-war trying to break back out of it.
As I advanced to try leveraging state, I wanted to keep this as simple a use case as possible, so I moved only the input
to a new component, leaving the form in the page.tsx
file.
'use client';
import { useState } from 'react';
export default function TextInput() {
const [newCompanyName, setNewCompanyName] = useState('');
return (
<input
value={newCompanyName}
onChange={(e) => setNewCompanyName(e.target.value)}
type="text"
name="companyName"
id="companyName"
placeholder="Enter company name"
required
/>
);
}
Note that since I’m using useState
, I needed to mark this component as a client component with "use client";
at the top, as the server is stateless and thus doesn’t, you know, handle state changes. I didn’t want to make the entire page a client component, as that would too broadly eliminate the benefits of server rendering. (Also, eslint became very upset with me when I tried, so I slowly backed away.)
My important realization here was that the client component still rendered just fine with JS disabled; it just didn’t have interactivity. I could still submit the form and see similar behaviors as before: A JS fetch (POST) or HTML POST if javascript is disabled.
From here, understanding React came back to the table and I moved the state management back where it belongs: outside of the input component, and at the form level:
const [newCompanyName, setNewCompanyName] = useState('');
return (
<form action={addCompany}>
<label htmlFor="companyName">Company Name</label>
<input
value={newCompanyName}
onChange={(e) => setNewCompanyName(e.target.value)}
type="text"
name="companyName"
id="companyName"
placeholder="Enter company name"
required
/>
<input type="submit" />
</form>
);
And now, when we want to do something after the form has submitted?
It’s as simple as a classic onSubmit
handler! (Just don’t shoot yourself in the foot and use event.preventDefault
in it.)
const [newCompanyName, setNewCompanyName] = useState('');
return (
<form action={addCompany} onSubmit={() => setNewCompanyName('')}>
<label htmlFor="companyName">Company Name</label>
<input
value={newCompanyName}
onChange={(e) => setNewCompanyName(e.target.value)}
type="text"
name="companyName"
id="companyName"
placeholder="Enter company name"
required
/>
<input type="submit" />
</form>
);
Now, is this done? No, not at all. Right now, the form will clear state while the submission is taking place, leaving the user in UI limbo for a moment. I’ll want to clarify to the user that something is happening behind the scenes and prepare for success, failure, and errors. But that’s a more common pattern I’ll leave to the reader (or maybe write up in another post as I play with the currently-experimental useOptimistic hook).
Takeaways
Here’s what I’ve learned from this, please ping me on Mastodon if I’ve come to any sort of incomplete conclusion here:
- “Server components” is a bit misleading - you can pretend it means “universal” as it indicates a component can be rendered on both client and server
- “Client components” (indicated by
"use client"
at the top of the file) can be statically rendered at build time; they just indicate the server shouldn’t try to run them as they rely on APIs that are only available to clients. - Form
action
props can be handled in both client and server environments, depending on the user need (e.g. if JS is supported in the client) onSubmit
still works after a client-sideaction
is run; just remember not to callevent.preventDefault
or your action won’t run.