Forms are a fundamental element of interactive web applications. They allow users to input data, which can then be processed by the application. In the React and Next.js environment, creating forms can be achieved in various ways - from simple native solutions to advanced libraries. In this module, we will look at different approaches to form implementation.
In its most basic form, a form in React is similar to a traditional HTML form, but with an additional layer of state management.
The most commonly used approach in React is "controlled components", where the value of each form element is maintained in React state:
1import { useState, FormEvent } from 'react';
2
3export default function SimpleForm() {
4 const [name, setName] = useState('');
5 const [email, setEmail] = useState('');
6 const [message, setMessage] = useState('');
7
8 function handleSubmit(event: FormEvent<HTMLFormElement>) {
9 event.preventDefault();
10 console.log('Form data:', { name, email, message });
11
12 // Here you can send data to the API
13 // fetch('/api/contact', {
14 // method: 'POST',
15 // body: JSON.stringify({ name, email, message }),
16 // headers: { 'Content-Type': 'application/json' }
17 // })
18
19 // Optionally reset the form
20 setName('');
21 setEmail('');
22 setMessage('');
23 }
24
25 return (
26 <form onSubmit={handleSubmit} className="space-y-4">
27 <div>
28 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
29 Full name
30 </label>
31 <input
32 type="text"
33 id="name"
34 value={name}
35 onChange={(e) => setName(e.target.value)}
36 className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
37 required
38 />
39 </div>
40
41 <div>
42 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
43 Email
44 </label>
45 <input
46 type="email"
47 id="email"
48 value={email}
49 onChange={(e) => setEmail(e.target.value)}
50 className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
51 required
52 />
53 </div>
54
55 <div>
56 <label htmlFor="message" className="block text-sm font-medium text-gray-700">
57 Message
58 </label>
59 <textarea
60 id="message"
61 value={message}
62 onChange={(e) => setMessage(e.target.value)}
63 rows={4}
64 className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
65 required
66 />
67 </div>
68
69 <button
70 type="submit"
71 className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
72 >
73 Send
74 </button>
75 </form>
76 );
77}In the example above:
onChange event updates state for each fieldonSubmit event for the whole form handles data submissionevent.preventDefault() prevents the default page refreshWhen a form has many fields, maintaining a separate state for each one can be cumbersome. A more scalable approach is to use a single state object:
1import { useState, ChangeEvent, FormEvent } from 'react';
2
3interface FormData {
4 name: string;
5 email: string;
6 subject: string;
7 message: string;
8 agreeToTerms: boolean;
9}
10
11export default function ContactForm() {
12 const [formData, setFormData] = useState<FormData>({
13 name: '',
14 email: '',
15 subject: '',
16 message: '',
17 agreeToTerms: false
18 });
19
20 const [errors, setErrors] = useState<Partial<FormData>>({});
21
22 function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) {
23 const { name, value, type } = e.target;
24 const checked = (e.target as HTMLInputElement).checked;
25
26 setFormData(prev => ({
27 ...prev,
28 [name]: type === 'checkbox' ? checked : value
29 }));
30
31 // Reset error during editing
32 if (errors[name as keyof FormData]) {
33 setErrors(prev => ({ ...prev, [name]: undefined }));
34 }
35 }
36
37 function validateForm(): boolean {
38 const newErrors: Partial<FormData> = {};
39 let isValid = true;
40
41 if (!formData.name.trim()) {
42 newErrors.name = 'Name is required';
43 isValid = false;
44 }
45
46 if (!formData.email.trim()) {
47 newErrors.email = 'Email is required';
48 isValid = false;
49 } else if (!/^S+@S+.S+$/.test(formData.email)) {
50 newErrors.email = 'Email is invalid';
51 isValid = false;
52 }
53
54 if (!formData.message.trim()) {
55 newErrors.message = 'Message is required';
56 isValid = false;
57 }
58
59 if (!formData.agreeToTerms) {
60 newErrors.agreeToTerms = 'You must agree to the terms';
61 isValid = false;
62 }
63
64 setErrors(newErrors);
65 return isValid;
66 }
67
68 function handleSubmit(e: FormEvent<HTMLFormElement>) {
69 e.preventDefault();
70
71 if (validateForm()) {
72 console.log('Submitting form:', formData);
73 // Send to API...
74
75 // Reset form after successful submission
76 setFormData({
77 name: '',
78 email: '',
79 subject: '',
80 message: '',
81 agreeToTerms: false
82 });
83 }
84 }
85
86 return (
87 <form onSubmit={handleSubmit} className="space-y-4">
88 <div>
89 <label htmlFor="name" className="block text-sm font-medium">
90 Full name *
91 </label>
92 <input
93 type="text"
94 id="name"
95 name="name"
96 value={formData.name}
97 onChange={handleChange}
98 className={`mt-1 block w-full rounded-md ${errors.name ? 'border-red-500' : 'border-gray-300'}`}
99 />
100 {errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
101 </div>
102
103 <div>
104 <label htmlFor="email" className="block text-sm font-medium">
105 Email *
106 </label>
107 <input
108 type="email"
109 id="email"
110 name="email"
111 value={formData.email}
112 onChange={handleChange}
113 className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
114 />
115 {errors.email && <p className="text-red-500 text-xs mt-1">{errors.email}</p>}
116 </div>
117
118 <div>
119 <label htmlFor="subject" className="block text-sm font-medium">
120 Subject
121 </label>
122 <select
123 id="subject"
124 name="subject"
125 value={formData.subject}
126 onChange={handleChange}
127 className="mt-1 block w-full rounded-md border-gray-300"
128 >
129 <option value="">Select subject</option>
130 <option value="general">General inquiry</option>
131 <option value="support">Technical support</option>
132 <option value="feedback">Feedback</option>
133 </select>
134 </div>
135
136 <div>
137 <label htmlFor="message" className="block text-sm font-medium">
138 Message *
139 </label>
140 <textarea
141 id="message"
142 name="message"
143 value={formData.message}
144 onChange={handleChange}
145 rows={4}
146 className={`mt-1 block w-full rounded-md ${errors.message ? 'border-red-500' : 'border-gray-300'}`}
147 />
148 {errors.message && <p className="text-red-500 text-xs mt-1">{errors.message}</p>}
149 </div>
150
151 <div className="flex items-center">
152 <input
153 type="checkbox"
154 id="agreeToTerms"
155 name="agreeToTerms"
156 checked={formData.agreeToTerms}
157 onChange={handleChange}
158 className={`h-4 w-4 ${errors.agreeToTerms ? 'border-red-500' : 'border-gray-300'}`}
159 />
160 <label htmlFor="agreeToTerms" className="ml-2 block text-sm">
161 I agree to the terms and privacy policy *
162 </label>
163 {errors.agreeToTerms && (
164 <p className="text-red-500 text-xs ml-2">{errors.agreeToTerms}</p>
165 )}
166 </div>
167
168 <button
169 type="submit"
170 className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
171 >
172 Send message
173 </button>
174 </form>
175 );
176}This approach offers the following advantages:
[name]: value syntaxNext.js 13+ introduced Server Actions, which enable direct form processing on the server without the need to create separate API endpoints.
1// app/signup/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { useRouter } from 'next/navigation';
6import { registerUser } from './actions';
7
8export default function SignupPage() {
9 const router = useRouter();
10 const [error, setError] = useState<string>('');
11 const [loading, setLoading] = useState(false);
12
13 async function handleSubmit(formData: FormData) {
14 setLoading(true);
15 setError('');
16
17 try {
18 // Call Server Action
19 const result = await registerUser(formData);
20
21 if (result.success) {
22 // Redirect after successful registration
23 router.push('/signup/confirm');
24 } else {
25 // Display error
26 setError(result.error || 'Something went wrong');
27 }
28 } catch (e) {
29 setError('An unexpected error occurred.');
30 } finally {
31 setLoading(false);
32 }
33 }
34
35 return (
36 <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
37 <h1 className="text-2xl font-bold mb-6">Registration</h1>
38
39 {error && (
40 <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
41 {error}
42 </div>
43 )}
44
45 <form action={handleSubmit}>
46 <div className="mb-4">
47 <label htmlFor="name" className="block text-sm font-medium mb-1">
48 Full name
49 </label>
50 <input
51 type="text"
52 id="name"
53 name="name"
54 required
55 className="w-full px-3 py-2 border border-gray-300 rounded-md"
56 />
57 </div>
58
59 <div className="mb-4">
60 <label htmlFor="email" className="block text-sm font-medium mb-1">
61 Email
62 </label>
63 <input
64 type="email"
65 id="email"
66 name="email"
67 required
68 className="w-full px-3 py-2 border border-gray-300 rounded-md"
69 />
70 </div>
71
72 <div className="mb-4">
73 <label htmlFor="password" className="block text-sm font-medium mb-1">
74 Password
75 </label>
76 <input
77 type="password"
78 id="password"
79 name="password"
80 required
81 minLength={8}
82 className="w-full px-3 py-2 border border-gray-300 rounded-md"
83 />
84 </div>
85
86 <button
87 type="submit"
88 disabled={loading}
89 className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
90 >
91 {loading ? 'Registering...' : 'Sign up'}
92 </button>
93 </form>
94 </div>
95 );
96}And here is the Server Action file:
1// app/signup/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Form validation schema
7const UserSchema = z.object({
8 name: z.string().min(2, { message: 'Name is too short' }),
9 email: z.string().email({ message: 'Invalid email address' }),
10 password: z.string().min(8, { message: 'Password must be at least 8 characters' })
11});
12
13type SignupResult =
14 | { success: true; userId: string }
15 | { success: false; error: string };
16
17export async function registerUser(formData: FormData): Promise<SignupResult> {
18 // 1. Validate form data
19 const validatedFields = UserSchema.safeParse({
20 name: formData.get('name'),
21 email: formData.get('email'),
22 password: formData.get('password')
23 });
24
25 if (!validatedFields.success) {
26 return {
27 success: false,
28 error: validatedFields.error.errors[0]?.message || 'Invalid form data'
29 };
30 }
31
32 const { name, email, password } = validatedFields.data;
33
34 try {
35 // 2. Check if user already exists
36 const existingUser = false; // Here should be the actual database check logic
37
38 if (existingUser) {
39 return {
40 success: false,
41 error: 'A user with this email address already exists'
42 };
43 }
44
45 // 3. Create new user (in a real application - save to database)
46 // const newUser = await db.user.create({ data: { name, email, password: hashedPassword } });
47
48 // 4. Additional operations - sending welcome email, logging in, etc.
49 // await sendWelcomeEmail(email, name);
50
51 return {
52 success: true,
53 userId: '123' // In a real application, the created user ID
54 };
55 } catch (error) {
56 console.error('Registration error:', error);
57 return {
58 success: false,
59 error: 'An error occurred during registration. Please try again later.'
60 };
61 }
62}Advantages of Server Actions:
We can combine Server Actions with React Hooks to provide an even better user experience:
1// app/contact/page.tsx
2'use client';
3
4import { useFormState } from 'react-dom';
5import { useFormStatus } from 'react-dom';
6import { submitContactForm } from './actions';
7
8// Button component with loading state handling
9function SubmitButton() {
10 const { pending } = useFormStatus();
11
12 return (
13 <button
14 type="submit"
15 disabled={pending}
16 className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
17 >
18 {pending ? 'Sending...' : 'Send message'}
19 </button>
20 );
21}
22
23export default function ContactPage() {
24 // Initial form state
25 const initialState = {
26 message: '',
27 errors: {},
28 success: false
29 };
30
31 // Using the useFormState hook to manage form state
32 const [state, formAction] = useFormState(submitContactForm, initialState);
33
34 return (
35 <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
36 <h1 className="text-2xl font-bold mb-6">Contact form</h1>
37
38 {state.success ? (
39 <div className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded">
40 Thank you for your message! We will respond as soon as possible.
41 </div>
42 ) : (
43 <form action={formAction} className="space-y-4">
44 {state.message && (
45 <div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded">
46 {state.message}
47 </div>
48 )}
49
50 <div>
51 <label htmlFor="name" className="block text-sm font-medium mb-1">
52 Full name *
53 </label>
54 <input
55 type="text"
56 id="name"
57 name="name"
58 className={`w-full px-3 py-2 border rounded-md ${
59 state.errors?.name ? 'border-red-500' : 'border-gray-300'
60 }`}
61 />
62 {state.errors?.name && (
63 <p className="text-red-500 text-xs mt-1">{state.errors.name}</p>
64 )}
65 </div>
66
67 <div>
68 <label htmlFor="email" className="block text-sm font-medium mb-1">
69 Email *
70 </label>
71 <input
72 type="email"
73 id="email"
74 name="email"
75 className={`w-full px-3 py-2 border rounded-md ${
76 state.errors?.email ? 'border-red-500' : 'border-gray-300'
77 }`}
78 />
79 {state.errors?.email && (
80 <p className="text-red-500 text-xs mt-1">{state.errors.email}</p>
81 )}
82 </div>
83
84 <div>
85 <label htmlFor="message" className="block text-sm font-medium mb-1">
86 Message *
87 </label>
88 <textarea
89 id="message"
90 name="message"
91 rows={4}
92 className={`w-full px-3 py-2 border rounded-md ${
93 state.errors?.messageContent ? 'border-red-500' : 'border-gray-300'
94 }`}
95 />
96 {state.errors?.messageContent && (
97 <p className="text-red-500 text-xs mt-1">{state.errors.messageContent}</p>
98 )}
99 </div>
100
101 <SubmitButton />
102 </form>
103 )}
104 </div>
105 );
106}And the related actions file:
1// app/contact/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Validation schema
7const ContactSchema = z.object({
8 name: z.string().min(1, 'Name is required'),
9 email: z.string().email('Invalid email address'),
10 messageContent: z.string().min(10, 'Message must be at least 10 characters')
11});
12
13// Form state type
14type FormState = {
15 message: string;
16 errors?: Record<string, string>;
17 success: boolean;
18};
19
20export async function submitContactForm(prevState: FormState, formData: FormData): Promise<FormState> {
21 // Prepare data for validation
22 const validationData = {
23 name: formData.get('name'),
24 email: formData.get('email'),
25 messageContent: formData.get('message')
26 };
27
28 // Validation
29 const result = ContactSchema.safeParse(validationData);
30
31 if (!result.success) {
32 // Prepare error structure
33 const errors: Record<string, string> = {};
34 result.error.errors.forEach(error => {
35 if (error.path[0]) {
36 errors[error.path[0].toString()] = error.message;
37 }
38 });
39
40 return {
41 message: 'The form contains errors. Fix them and try again.',
42 errors,
43 success: false
44 };
45 }
46
47 try {
48 // Simulate network delay
49 await new Promise(resolve => setTimeout(resolve, 1000));
50
51 // Here would be the code to send the message, e.g. save to database or send email
52 // await sendContactEmail(result.data);
53
54 // Return success information
55 return {
56 message: '',
57 success: true
58 };
59 } catch (error) {
60 return {
61 message: 'An error occurred while sending the message. Please try again later.',
62 success: false
63 };
64 }
65}In this example, we use:
Beyond the native approach, there are several excellent libraries that simplify working with forms in React and Next.js.
React Hook Form is one of the most popular form handling libraries in React, which minimizes rendering and optimizes performance.
1import { useForm, SubmitHandler } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4
5// Validation schema definition
6const loginFormSchema = z.object({
7 email: z.string().email('Invalid email address'),
8 password: z.string().min(6, 'Password must be at least 6 characters')
9});
10
11// Form data type inferred from Zod schema
12type LoginFormInputs = z.infer<typeof loginFormSchema>;
13
14export default function LoginForm() {
15 const {
16 register,
17 handleSubmit,
18 formState: { errors, isSubmitting }
19 } = useForm<LoginFormInputs>({
20 resolver: zodResolver(loginFormSchema),
21 defaultValues: {
22 email: '',
23 password: ''
24 }
25 });
26
27 const onSubmit: SubmitHandler<LoginFormInputs> = async (data) => {
28 try {
29 // Simulate network delay
30 await new Promise(resolve => setTimeout(resolve, 1000));
31
32 console.log('Login data:', data);
33 // In a real application: call login API
34 // await signIn('credentials', { email: data.email, password: data.password });
35 } catch (error) {
36 console.error('Login error:', error);
37 }
38 };
39
40 return (
41 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
42 <div>
43 <label htmlFor="email" className="block text-sm font-medium">
44 Email
45 </label>
46 <input
47 id="email"
48 type="email"
49 className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
50 {...register('email')}
51 />
52 {errors.email && (
53 <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
54 )}
55 </div>
56
57 <div>
58 <label htmlFor="password" className="block text-sm font-medium">
59 Password
60 </label>
61 <input
62 id="password"
63 type="password"
64 className={`mt-1 block w-full rounded-md ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
65 {...register('password')}
66 />
67 {errors.password && (
68 <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>
69 )}
70 </div>
71
72 <button
73 type="submit"
74 disabled={isSubmitting}
75 className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
76 >
77 {isSubmitting ? 'Logging in...' : 'Log in'}
78 </button>
79 </form>
80 );
81}Advantages of React Hook Form:
Formik is another popular form library in React that offers a more declarative approach:
1import { Formik, Form, Field, ErrorMessage } from 'formik';
2import * as Yup from 'yup';
3
4// Yup validation schema
5const CheckoutSchema = Yup.object().shape({
6 firstName: Yup.string().required('Name is required'),
7 lastName: Yup.string().required('Last name is required'),
8 email: Yup.string().email('Invalid email').required('Email is required'),
9 address: Yup.string().required('Address is required'),
10 city: Yup.string().required('City is required'),
11 postalCode: Yup.string()
12 .matches(/^d{2}-d{3}$/, 'Invalid postal code (e.g. 00-000)')
13 .required('Postal code is required'),
14 paymentMethod: Yup.string().required('Select payment method')
15});
16
17export default function CheckoutForm() {
18 return (
19 <div className="max-w-md mx-auto mt-10">
20 <h1 className="text-2xl font-bold mb-6">Order Checkout</h1>
21
22 <Formik
23 initialValues={{
24 firstName: '',
25 lastName: '',
26 email: '',
27 address: '',
28 city: '',
29 postalCode: '',
30 paymentMethod: ''
31 }}
32 validationSchema={CheckoutSchema}
33 onSubmit={async (values, { setSubmitting, resetForm }) => {
34 try {
35 // Simulate delay
36 await new Promise(resolve => setTimeout(resolve, 1000));
37 console.log('Checkout data:', values);
38
39 // In a real application: call API
40 // await processCheckout(values);
41
42 resetForm();
43 alert('Order accepted!');
44 } catch (error) {
45 console.error('Checkout error:', error);
46 alert('An error occurred while placing the order');
47 } finally {
48 setSubmitting(false);
49 }
50 }}
51 >
52 {({ isSubmitting }) => (
53 <Form className="space-y-4">
54 <div className="grid grid-cols-2 gap-4">
55 <div>
56 <label htmlFor="firstName" className="block text-sm font-medium">
57 First name
58 </label>
59 <Field
60 id="firstName"
61 name="firstName"
62 type="text"
63 className="mt-1 block w-full rounded-md border-gray-300"
64 />
65 <ErrorMessage name="firstName" component="div" className="text-red-500 text-xs mt-1" />
66 </div>
67
68 <div>
69 <label htmlFor="lastName" className="block text-sm font-medium">
70 Last name
71 </label>
72 <Field
73 id="lastName"
74 name="lastName"
75 type="text"
76 className="mt-1 block w-full rounded-md border-gray-300"
77 />
78 <ErrorMessage name="lastName" component="div" className="text-red-500 text-xs mt-1" />
79 </div>
80 </div>
81
82 <div>
83 <label htmlFor="email" className="block text-sm font-medium">
84 Email
85 </label>
86 <Field
87 id="email"
88 name="email"
89 type="email"
90 className="mt-1 block w-full rounded-md border-gray-300"
91 />
92 <ErrorMessage name="email" component="div" className="text-red-500 text-xs mt-1" />
93 </div>
94
95 <div>
96 <label htmlFor="address" className="block text-sm font-medium">
97 Address
98 </label>
99 <Field
100 id="address"
101 name="address"
102 type="text"
103 className="mt-1 block w-full rounded-md border-gray-300"
104 />
105 <ErrorMessage name="address" component="div" className="text-red-500 text-xs mt-1" />
106 </div>
107
108 <div className="grid grid-cols-2 gap-4">
109 <div>
110 <label htmlFor="city" className="block text-sm font-medium">
111 City
112 </label>
113 <Field
114 id="city"
115 name="city"
116 type="text"
117 className="mt-1 block w-full rounded-md border-gray-300"
118 />
119 <ErrorMessage name="city" component="div" className="text-red-500 text-xs mt-1" />
120 </div>
121
122 <div>
123 <label htmlFor="postalCode" className="block text-sm font-medium">
124 Postal code
125 </label>
126 <Field
127 id="postalCode"
128 name="postalCode"
129 type="text"
130 className="mt-1 block w-full rounded-md border-gray-300"
131 />
132 <ErrorMessage name="postalCode" component="div" className="text-red-500 text-xs mt-1" />
133 </div>
134 </div>
135
136 <div>
137 <label className="block text-sm font-medium mb-2">
138 Payment method
139 </label>
140
141 <div className="space-y-2">
142 <label className="flex items-center">
143 <Field type="radio" name="paymentMethod" value="card" className="mr-2" />
144 Credit card
145 </label>
146
147 <label className="flex items-center">
148 <Field type="radio" name="paymentMethod" value="blik" className="mr-2" />
149 BLIK
150 </label>
151
152 <label className="flex items-center">
153 <Field type="radio" name="paymentMethod" value="transfer" className="mr-2" />
154 Bank transfer
155 </label>
156 </div>
157
158 <ErrorMessage name="paymentMethod" component="div" className="text-red-500 text-xs mt-1" />
159 </div>
160
161 <button
162 type="submit"
163 disabled={isSubmitting}
164 className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
165 >
166 {isSubmitting ? 'Processing...' : 'Place order'}
167 </button>
168 </Form>
169 )}
170 </Formik>
171 </div>
172 );
173}Advantages of Formik:
Form, Field, ErrorMessageIn this module, we explored different approaches to creating forms in React and Next.js:
Choosing the right approach depends on the specifics of the project:
Regardless of the chosen approach, it is worth keeping in mind a few good practices:
In the next modules, we will explore more advanced form handling techniques, including file uploads, multi-step forms, and integrations with external APIs.