Next.js course Β· Module 6: Forms and Server Actions

Creating Forms in React and Next.js

15 min read
In this lesson4

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.

Basic Forms in React

In its most basic form, a form in React is similar to a traditional HTML form, but with an additional layer of state management.

Controlled Form Components

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:

  1. Each form field is connected to a corresponding state variable
  2. The onChange event updates state for each field
  3. The onSubmit event for the whole form handles data submission
  4. Using event.preventDefault() prevents the default page refresh

Managing Multiple Form Fields

When 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:

  1. A single handleChange function handles all fields
  2. Dynamic state updates thanks to the [name]: value syntax
  3. TypeScript typing ensures type safety
  4. Validation with error messages improves user experience

Forms in Next.js with Server Actions

Next.js 13+ introduced Server Actions, which enable direct form processing on the server without the need to create separate API endpoints.

Basic Form with Server Action

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:

  1. Simplified architecture - no need to create separate API Routes
  2. Progressive enhancement - forms work even without JavaScript
  3. Secure data processing - verification and processing logic runs on the server
  4. End-to-end typing - TypeScript can provide typing from form to database

Forms with Loading States and Validation

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:

  1. useFormState - for managing form state between client and server
  2. useFormStatus - for tracking form submission status (pending)
  3. Zod - for validating form data on the server
  4. Functional components - for organizing UI logic

Beyond the native approach, there are several excellent libraries that simplify working with forms in React and Next.js.

React Hook Form

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:

  1. Performance - minimally re-renders components
  2. Easy integration - works with unmodified HTML form elements
  3. Advanced validation - native integration with Zod, Yup, and other libraries
  4. Unconventional validation forms - validation dependent on other fields, conditional validation, etc.

Formik

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:

  1. Deklaratywny API - komponenty Form, Field, ErrorMessage
  2. Good documentation - extensive documentation and examples
  3. Maturity - stable and proven in production
  4. Integration with Yup - easy validation with the Yup library

Summary

In this module, we explored different approaches to creating forms in React and Next.js:

  1. Natywne formularze React - kontrolowane komponenty z lokalnym stanem
  2. Server Actions in Next.js - a new server-side approach
  3. React Hook Form - an efficient library with minimal rendering
  4. Formik - a mature library with declarative API

Choosing the right approach depends on the specifics of the project:

  • For simple forms: native React solutions
  • For Next.js applications: Server Actions with Progressive Enhancement
  • For complex client-side forms: React Hook Form or Formik

Regardless of the chosen approach, it is worth keeping in mind a few good practices:

  • Walidacja na wielu poziomach - klient + serwer
  • Accessibility - proper labels, ARIA attributes, keyboard support
  • Feedback - loading states, errors, confirmations
  • Security - CSRF protection, server-side data validation

In the next modules, we will explore more advanced form handling techniques, including file uploads, multi-step forms, and integrations with external APIs.

Code for this lesson: App.tsx
1// Demo: Creating forms in React and Next.js
2// Form basics: controlled components, events, submit
3import React, { useState, FormEvent } from 'react';
4
5// In React forms work as "controlled components"
6// React state controls form field values
7// onChange updates state -> React re-renders input with new value
8
9// Basic pattern:
10// const [value, setValue] = useState('');
11// <input value={value} onChange={e => setValue(e.target.value)} />
12
13interface ContactData {
14  name: string;
15  email: string;
16  subject: string;
17  message: string;
18  priority: 'low' | 'medium' | 'high';
19}
20
21const initialData: ContactData = {
22  name: '',
23  email: '',
24  subject: '',
25  message: '',
26  priority: 'medium',
27};
28
29function FormInput({ label, id, type = 'text', value, onChange, placeholder }: {
30  label: string; id: string; type?: string;
31  value: string; onChange: (val: string) => void; placeholder?: string;
32}) {
33  return (
34    <div style={{ marginBottom: '16px' }}>
35      <label htmlFor={id} style={{
36        display: 'block', color: '#b0bec5', fontSize: '0.85rem', marginBottom: '6px',
37      }}>{label}</label>
38      <input
39        id={id} type={type} value={value}
40        onChange={e => onChange(e.target.value)}
41        placeholder={placeholder}
42        style={{
43          width: '100%', padding: '12px 16px', boxSizing: 'border-box',
44          background: 'rgba(255,255,255,0.05)',
45          border: '1px solid rgba(100,255,218,0.3)',
46          borderRadius: '8px', color: '#fff', fontSize: '1rem', outline: 'none',
47        }}
48      />
49    </div>
50  );
51}
52
53export default function ContactFormDemo() {
54  const [formData, setFormData] = useState<ContactData>(initialData);
55  const [submitted, setSubmitted] = useState(false);
56  const [submittedData, setSubmittedData] = useState<ContactData | null>(null);
57
58  const updateField = (field: keyof ContactData) => (value: string) => {
59    setFormData(prev => ({ ...prev, [field]: value }));
60  };
61
62  const handleSubmit = (e: FormEvent) => {
63    e.preventDefault(); // Prevent default page reload
64    setSubmittedData(formData);
65    setSubmitted(true);
66  };
67
68  const handleReset = () => {
69    setFormData(initialData);
70    setSubmitted(false);
71    setSubmittedData(null);
72  };
73
74  const priorityColors = { low: '#4caf50', medium: '#ff9800', high: '#f44336' };
75
76  return (
77    <div style={{
78      background: '#0f0f23', minHeight: '100vh', padding: '24px',
79      color: '#fff', fontFamily: 'system-ui, sans-serif',
80      display: 'flex', justifyContent: 'center',
81    }}>
82      <div style={{ maxWidth: '500px', width: '100%' }}>
83        <h1 style={{ color: '#64ffda', marginBottom: '4px' }}>Quantum Contact Form</h1>
84        <p style={{ color: '#b0bec5', marginBottom: '24px', fontSize: '0.9rem' }}>
85          Controlled components - React manages form state
86        </p>
87
88        <form onSubmit={handleSubmit}>
89          <FormInput label="Name" id="name" value={formData.name}
90            onChange={updateField('name')} placeholder="Agent name..." />
91          <FormInput label="Email" id="email" type="email" value={formData.email}
92            onChange={updateField('email')} placeholder="agent@quantum.io" />
93          <FormInput label="Subject" id="subject" value={formData.subject}
94            onChange={updateField('subject')} placeholder="Subject..." />
95
96          <div style={{ marginBottom: '16px' }}>
97            <label style={{ display: 'block', color: '#b0bec5', fontSize: '0.85rem', marginBottom: '8px' }}>
98              Priority
99            </label>
100            <div style={{ display: 'flex', gap: '8px' }}>
101              {(['low', 'medium', 'high'] as const).map(p => (
102                <button key={p} type="button"
103                  onClick={() => updateField('priority')(p)}
104                  style={{
105                    flex: 1, padding: '10px', borderRadius: '8px',
106                    background: formData.priority === p ? `${priorityColors[p]}20` : 'transparent',
107                    border: `1px solid ${formData.priority === p ? priorityColors[p] : 'rgba(255,255,255,0.1)'}`,
108                    color: formData.priority === p ? priorityColors[p] : '#78909c',
109                    cursor: 'pointer', textTransform: 'capitalize',
110                  }}
111                >{p}</button>
112              ))}
113            </div>
114          </div>
115
116          <div style={{ marginBottom: '16px' }}>
117            <label htmlFor="message" style={{
118              display: 'block', color: '#b0bec5', fontSize: '0.85rem', marginBottom: '6px',
119            }}>Message</label>
120            <textarea id="message" rows={4} value={formData.message}
121              onChange={e => updateField('message')(e.target.value)}
122              style={{
123                width: '100%', padding: '12px', boxSizing: 'border-box',
124                background: 'rgba(255,255,255,0.05)',
125                border: '1px solid rgba(100,255,218,0.3)',
126                borderRadius: '8px', color: '#fff', fontSize: '1rem',
127                outline: 'none', resize: 'vertical',
128              }}
129            />
130          </div>
131
132          <div style={{ display: 'flex', gap: '10px' }}>
133            <button type="submit" style={{
134              flex: 2, background: '#64ffda', color: '#0f0f23',
135              border: 'none', padding: '14px', borderRadius: '8px',
136              cursor: 'pointer', fontWeight: 600, fontSize: '1rem',
137            }}>Submit</button>
138            <button type="button" onClick={handleReset} style={{
139              flex: 1, background: 'transparent', color: '#78909c',
140              border: '1px solid rgba(255,255,255,0.1)', padding: '14px',
141              borderRadius: '8px', cursor: 'pointer',
142            }}>Reset</button>
143          </div>
144        </form>
145
146        {/* Live React state preview */}
147        <div style={{
148          marginTop: '24px', background: 'rgba(0,0,0,0.3)',
149          borderRadius: '8px', padding: '16px',
150        }}>
151          <h4 style={{ color: '#78909c', marginBottom: '8px', fontSize: '0.8rem' }}>
152            React State (live):
153          </h4>
154          <pre style={{ margin: 0, color: '#b0bec5', fontSize: '0.75rem', lineHeight: 1.5 }}>
155            {JSON.stringify(formData, null, 2)}
156          </pre>
157        </div>
158
159        {submitted && submittedData && (
160          <div style={{
161            marginTop: '16px', background: 'rgba(76,175,80,0.1)',
162            border: '1px solid rgba(76,175,80,0.3)',
163            borderRadius: '8px', padding: '16px',
164          }}>
165            <h4 style={{ color: '#4caf50', marginBottom: '8px' }}>Sent!</h4>
166            <p style={{ color: '#b0bec5', fontSize: '0.85rem', margin: 0 }}>
167              From: {submittedData.name} ({submittedData.email})
168            </p>
169          </div>
170        )}
171      </div>
172    </div>
173  );
174}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. In React, an <input> element in a form is controlled when:

Useful articles