Skip to main content

Next.js Form State

Managing form state is a critical aspect of building interactive web applications with Next.js. Form state refers to the data and status of form elements (like inputs, checkboxes, and selects) as users interact with them. In this guide, we'll explore various approaches to manage form state in Next.js applications.

Introduction to Form State

Form state in Next.js (and React in general) includes:

  • Input values: The data entered by users into form fields
  • Form validation state: Whether inputs are valid or contain errors
  • Submission state: Whether the form is currently submitting or has been submitted
  • Error states: Any errors that occur during validation or form submission

Managing these aspects efficiently ensures a smooth user experience and proper data handling in your application.

Basic Form State Management

Controlled Components

The most straightforward approach to managing form state in Next.js is using controlled components. In this pattern, React state is the "single source of truth" for form values.

Here's a basic example:

jsx
"use client";

import { useState } from 'react';

export default function SimpleForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
});

const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prevData => ({
...prevData,
[name]: value
}));
};

const handleSubmit = (e) => {
e.preventDefault();
console.log('Form submitted with:', formData);
// Send data to your API here
};

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name:</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
</div>
<button type="submit">Submit</button>
</form>
);
}

Uncontrolled Components with useRef

For simpler forms, you can use uncontrolled components with useRef:

jsx
"use client";

import { useRef } from 'react';

export default function UncontrolledForm() {
const nameRef = useRef();
const emailRef = useRef();

const handleSubmit = (e) => {
e.preventDefault();
const formData = {
name: nameRef.current.value,
email: emailRef.current.value
};
console.log('Form submitted with:', formData);
// Send data to your API
};

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name:</label>
<input
type="text"
id="name"
name="name"
ref={nameRef}
defaultValue=""
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
ref={emailRef}
defaultValue=""
/>
</div>
<button type="submit">Submit</button>
</form>
);
}

Form Validation States

Form validation is crucial for data integrity. Let's enhance our controlled components example with validation:

jsx
"use client";

import { useState } from 'react';

export default function ValidatedForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
});

const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);

const validateForm = () => {
const newErrors = {};

// Name validation
if (!formData.name.trim()) {
newErrors.name = "Name is required";
}

// Email validation
if (!formData.email) {
newErrors.email = "Email is required";
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = "Email is invalid";
}

setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prevData => ({
...prevData,
[name]: value
}));

// Clear error when user types
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: null
}));
}
};

const handleSubmit = async (e) => {
e.preventDefault();

if (validateForm()) {
setIsSubmitting(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Form submitted successfully:', formData);
// Reset form after successful submission
setFormData({ name: '', email: '' });
} catch (error) {
console.error('Error submitting form:', error);
} finally {
setIsSubmitting(false);
}
}
};

return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name:</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className={errors.name ? "input-error" : ""}
/>
{errors.name && <p className="error-text">{errors.name}</p>}
</div>

<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className={errors.email ? "input-error" : ""}
/>
{errors.email && <p className="error-text">{errors.email}</p>}
</div>

<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}

Form State with React Hook Form

For complex forms, managing state manually can become cumbersome. Libraries like React Hook Form can simplify form state management significantly:

jsx
"use client";

import { useForm } from 'react-hook-form';

export default function HookForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset
} = useForm();

const onSubmit = async (data) => {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Form submitted successfully:', data);
reset(); // Reset form on success
} catch (error) {
console.error('Error submitting form:', error);
}
};

return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
{...register("name", {
required: "Name is required"
})}
className={errors.name ? "input-error" : ""}
/>
{errors.name && <p className="error-text">{errors.name.message}</p>}
</div>

<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
{...register("email", {
required: "Email is required",
pattern: {
value: /\S+@\S+\.\S+/,
message: "Email is invalid"
}
})}
className={errors.email ? "input-error" : ""}
/>
{errors.email && <p className="error-text">{errors.email.message}</p>}
</div>

<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}

Real-world Example: User Registration Form

Let's build a more comprehensive example of a user registration form that showcases form state management with additional features:

jsx
"use client";

import { useState } from 'react';
import { useForm } from 'react-hook-form';

export default function UserRegistrationForm() {
const [showPassword, setShowPassword] = useState(false);
const [serverError, setServerError] = useState('');
const [registrationSuccess, setRegistrationSuccess] = useState(false);

const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
reset
} = useForm();

const password = watch('password', '');

const toggleShowPassword = () => {
setShowPassword(!showPassword);
};

const onSubmit = async (data) => {
setServerError('');
try {
// Simulate API call with random success/failure
await new Promise(resolve => setTimeout(resolve, 1500));

// Simulate random server error (for demonstration)
if (Math.random() > 0.7) {
throw new Error('Username already taken');
}

console.log('Registration successful:', data);
setRegistrationSuccess(true);
reset();
} catch (error) {
setServerError(error.message || 'Registration failed. Please try again.');
}
};

if (registrationSuccess) {
return (
<div className="success-message">
<h2>Registration Successful!</h2>
<p>Thank you for registering. You can now log in to your account.</p>
<button onClick={() => setRegistrationSuccess(false)}>Register Another User</button>
</div>
);
}

return (
<div className="form-container">
<h2>Create an Account</h2>

{serverError && (
<div className="server-error">
<p>{serverError}</p>
</div>
)}

<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
{...register("username", {
required: "Username is required",
minLength: {
value: 3,
message: "Username must be at least 3 characters"
}
})}
className={errors.username ? "input-error" : ""}
/>
{errors.username && <p className="error-text">{errors.username.message}</p>}
</div>

<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
{...register("email", {
required: "Email is required",
pattern: {
value: /\S+@\S+\.\S+/,
message: "Please enter a valid email"
}
})}
className={errors.email ? "input-error" : ""}
/>
{errors.email && <p className="error-text">{errors.email.message}</p>}
</div>

<div className="form-group">
<label htmlFor="password">Password</label>
<div className="password-input">
<input
id="password"
type={showPassword ? "text" : "password"}
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters"
},
pattern: {
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: "Password must contain at least one uppercase letter, one lowercase letter, and one number"
}
})}
className={errors.password ? "input-error" : ""}
/>
<button
type="button"
className="toggle-password"
onClick={toggleShowPassword}
>
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
{errors.password && <p className="error-text">{errors.password.message}</p>}
</div>

<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type={showPassword ? "text" : "password"}
{...register("confirmPassword", {
required: "Please confirm your password",
validate: value => value === password || "Passwords do not match"
})}
className={errors.confirmPassword ? "input-error" : ""}
/>
{errors.confirmPassword && <p className="error-text">{errors.confirmPassword.message}</p>}
</div>

<div className="form-group checkbox">
<input
id="terms"
type="checkbox"
{...register("terms", {
required: "You must accept the terms and conditions"
})}
/>
<label htmlFor="terms">I agree to the Terms and Conditions</label>
{errors.terms && <p className="error-text">{errors.terms.message}</p>}
</div>

<button
type="submit"
className="submit-button"
disabled={isSubmitting}
>
{isSubmitting ? 'Creating Account...' : 'Register'}
</button>
</form>
</div>
);
}

Server Actions with Next.js Form State

Next.js 13+ introduces Server Actions, which can be used alongside client-side form state management. Here's how to combine them:

jsx
// app/actions.js
'use server';

export async function createUser(formData) {
// Server-side validation
const name = formData.get('name');
const email = formData.get('email');

if (!name || name.length < 3) {
return { error: 'Name must be at least 3 characters' };
}

if (!email || !/\S+@\S+\.\S+/.test(email)) {
return { error: 'Valid email is required' };
}

try {
// Database operations would go here
console.log('Creating user:', { name, email });

// Simulate successful user creation
return { success: true };
} catch (error) {
return { error: 'Failed to create user. Please try again.' };
}
}
jsx
// app/register/page.jsx
'use client';

import { useState } from 'react';
import { createUser } from '../actions';

export default function RegisterPage() {
const [formData, setFormData] = useState({
name: '',
email: ''
});
const [formStatus, setFormStatus] = useState({
isSubmitting: false,
error: null,
success: false
});

const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};

const handleSubmit = async (e) => {
e.preventDefault();
setFormStatus({ isSubmitting: true, error: null, success: false });

// Collect form data for server action
const formDataObj = new FormData();
formDataObj.append('name', formData.name);
formDataObj.append('email', formData.email);

try {
const result = await createUser(formDataObj);

if (result.error) {
setFormStatus({
isSubmitting: false,
error: result.error,
success: false
});
} else {
setFormStatus({
isSubmitting: false,
error: null,
success: true
});
setFormData({ name: '', email: '' });
}
} catch (error) {
setFormStatus({
isSubmitting: false,
error: 'An unexpected error occurred',
success: false
});
}
};

return (
<div className="form-container">
<h1>Register</h1>

{formStatus.success && (
<div className="success-message">
User registered successfully!
</div>
)}

{formStatus.error && (
<div className="error-message">
{formStatus.error}
</div>
)}

<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
</div>

<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>

<button
type="submit"
disabled={formStatus.isSubmitting}
>
{formStatus.isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
</div>
);
}

Summary

Managing form state in Next.js involves handling:

  1. Input values through controlled or uncontrolled components
  2. Validation state for ensuring data integrity
  3. Submission state for better user experience
  4. Error states to provide feedback to users

You can choose between:

  • Manual state management with React's useState
  • Uncontrolled components with useRef
  • Form libraries like React Hook Form
  • Next.js Server Actions combined with client-side state

The approach you choose depends on the complexity of your forms and specific requirements of your application.

Additional Resources

Exercises

  1. Create a multi-step form wizard that maintains state between steps using React context
  2. Implement a dynamic form where fields can be added or removed by the user
  3. Build a form with complex validation rules (password strength, unique username check, etc.)
  4. Create a form that uses both client-side validation and Next.js Server Actions for server-side validation


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)