Want a straightforward article on submitting a form in Nextjs to a server action using an HTML event onBlur
? And with form validation?
Then this is the article for you. I found the documentation on Nextjs.org lacking, so I'm putting this here for you.
This article is suitable for other events, such as onChange
. Still, I prefer onBlur
; as you know, the user is done typing, and you don't have to mess with debouncing.
The Form
This tutorial has a simple input
field within the form
asking for a name.
I want the user to save the field without hitting a submit button. So, the form has no buttons; the user types and the code does the rest.
"use client";
import React from "react";
import { useFormState } from "react-dom";
import { saveName } from "@/src/libs/actions/SaveName";
export default function NameForm() {
const initialState = { message: "", errors: {} };
const [state, formAction] = useFormState(saveName, initialState);
return (
<form action={formAction}>
{state && state.message && <div>{state.message}</div>}
<div>
<label htmlFor="name">
Enter your name
</label>
<div>
<input
name="name"
id="name"
type="text"
required
placeholder="Ex. Brandon Sanderson"
onBlur={async (e) => {
const formData = new FormData();
formData.append("name", e.target.value);
await formAction(formData);
}}
/>
{state && state.errors?.name &&
name((error: string) => (
<p key={error}>
{error}
</p>
))}
</div>
</div>
</form>
);
};
Okay, let's break this down. To make our lives easier and provide error messages, we're using the React hook useFormState
. As of this writing, this newer feature is only available in a Canary build. But it's been stable for me.
const initialState = { message: "", errors: {} };
const [state, formAction] = useFormState(saveName, initialState);
We first create a state object, which we'll use to handle our validation messages.
We then create our state object and formAction by passing to useFormState a reference to our function (a NextJS server action) and the initial state.
<form action={formAction}>
We use the Nextjs syntax to call our server action in our form. If you don't include this when a user hits enter within a field, it will not call your server action.
<input
name="name"
id="name"
type="text"
required
placeholder="Ex. Brandon Sanderson"
onBlur={async (e) => {
const formData = new FormData();
formData.append("name", e.target.value);
await formAction(formData);
}}
/>
The input
is standard right up to the onBlur
. We cannot call the form's action
from onBlur
, but we can trigger the same action ourselves. But you must make a FormData
object yourself. FormData
is just a simple interface; you can create it with a call to new FormData()
. We then populate it with an append
, which requires a key/value pair. Then, we call our server action created via the hook.
The Server Action
For the server action, we'll include validation since we want to give the user an experience that lets them know if something has gone wrong.
"use server";
import { z } from 'zod';
const NameFormSchema = z.object({
name: z.string().min(2, { message: 'You must enter at least two characters'}),
});
export type State = {
errors?: {
name?: string[];
};
message?: string | null;
}
export async function saveWebsite (prevState: State | undefined, formData: FormData) {
// Validate Inputs
const validatedFields = WebsiteFormSchema.safeParse({
name: formData.get('name'),
});
// Handle Validation Errors
if (!validatedFields.success) {
const state: State = {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Oops, I think there\'s a mistake with your inputs.',
}
return state;
}
// Do your save here...
}
I'm a big fan of Zod, a typescript validation library. You could roll this yourself. But why do life in hard mode?
Let's break this code down
const NameFormSchema = z.object({
name: z.string().min(2, { message: 'You must enter at least two characters'}),
});
Zod uses a schema format. So first, you need to tell it the schema of the form you'll be using. Each field you're submitting will have an entry. In this case, just one field.
export type State = {
errors?: {
name?: string[];
};
message?: string | null;
}
This is the type we'll be using for our form state. We must define its type fully because we're populating it and using TypeScript. We use this object to pass back errors. If you look at the form, you can see how it's used, as it's fairly straightforward.
// Validate Inputs
const validatedFields = WebsiteFormSchema.safeParse({
name: formData.get('name'),
});
// Handle Validation Errors
if (!validatedFields.success) {
const state: State = {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Oops, I think there\'s a mistake with your inputs.',
}
return state;
}
Validation is also straightforward with Zod. First, we safely parse the fields we want. Then, we check for any errors that were created during the parse. If they exist, we package them up into our state object.
The last step is up to you. Save to a database or call an API. But be sure to capture any errors and use the state object if they occur.
Top comments (5)
Hi. Thanks for sharing. I have a couple of questions:
Thanks!
onChange
would call the formAction every single keystroke, which isn't very efficient.onBlur
will persist the data once the change is complete.I see.
I have a form where I need to capture the users interaction immediately. It's during signup and I don't want any save buttons and don't want any lost data. It can be inefficient, but it's a limited case and the payoff for less friction is worth it.
In my use case the user can't advance to another page, they're mid onboarding. But if that could be the case you'd need to handle the outcomes.
grtgrtg