Using React Hooks to Simplify Complex Forms

Managing complex forms in React can quickly become overwhelming, especially as the number of fields, validation requirements, and interdependent behaviours grow. Developers often face challenges like juggling multiple state variables, handling nested fields, ensuring dynamic field updates, and managing validation logic without cluttering their components. Traditional approaches can lead to bloated, hard-to-maintain code, making the development process inefficient.

React Hooks provide a powerful, flexible way to simplify form handling by encapsulating state management and behaviour logic. Hooks like useState, useReducer, and custom hooks enable you to handle even the most complex forms with cleaner, more maintainable code. These tools let you break down the form’s functionality into reusable, composable pieces, reducing the need for repetitive boilerplate code.

Setting Up the Basic Form Structure

This foundation will allow us to integrate hooks for state management and other features as we proceed. A simple form typically consists of HTML form elements wrapped within a React component. Each field should be a controlled component, meaning its value is managed by React state. This approach ensures that React has complete control over the form’s behaviour and data.

To keep your form scalable, it’s a good idea to organize fields into reusable components or sections, especially for forms with multiple steps or grouped inputs.

import React, { useState } from "react";

function BasicForm() {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    message: "",
  });

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

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log("Form Submitted:", formData);
  };

  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>

      <div>
        <label htmlFor="message">Message:</label>
        <textarea
          id="message"
          name="message"
          value={formData.message}
          onChange={handleChange}
        ></textarea>
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

export default BasicForm;

Organizing Form Fields

  • For large forms, divide fields into sections (e.g., personal info, contact details) to make the form easier to read and manage.
  • Create reusable form field components like <TextInput /> or <TextArea /> to avoid repetitive code. For example:
const TextInput = ({ label, name, value, onChange }) => (
  <div>
    <label htmlFor={name}>{label}:</label>
    <input
      type="text"
      id={name}
      name={name}
      value={value}
      onChange={onChange}
    />
  </div>
);
  • Use a single state object to store all field values, as shown in the example, to simplify updates and debugging.
  • Organize fields with scalability in mind, such as dynamically rendering inputs based on state or props.

Managing Form State with useState

The useState hook in React is a simple and effective way to manage form state. It allows you to track the values of form fields and update them dynamically as the user interacts with the form. Controlled inputs—where the value of an input is tied to React state—ensure that React has full control over the form’s behaviour, enabling real-time updates and validations.

Using useState for form state involves:

  1. Initializing State: Create a state object to hold the values of all form fields.
  2. Updating State: Use the onChange event of each input field to update the corresponding value in the state object.
  3. Controlled Inputs: Tie each input’s value attribute to the state, ensuring the UI and state are always in sync.

This approach ensures a single source of truth for your form data, making it easier to manage and debug.

import React, { useState } from "react";

function FormWithUseState() {
  const [formData, setFormData] = useState({
    username: "",
    email: "",
    password: "",
  });

  const handleChange = (event) => {
    const { name, value } = event.target;
    setFormData((prevData) => ({
      ...prevData,
      [name]: value, // Dynamically update the corresponding field
    }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log("Form Data:", formData); // Log the form data for demonstration
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">Username:</label>
        <input
          type="text"
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
        />
      </div>

      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
      </div>

      <div>
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
        />
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

export default FormWithUseState;

Updating Fields Dynamically

In this example, as the user types into any input field, the onChange handler updates the corresponding value in the formData state object. For instance:

  • Typing "John" in the username field updates formData.username to "John".
  • The value attribute ensures the input reflects the updated state, creating a two-way data binding between the UI and the state.

Tips

  • Use the name attribute of the input fields to dynamically update state properties. This eliminates the need for individual onChange handlers for each field.
  • Use console.log(formData) inside the handleChange function to monitor state updates in real-time during development.
  • For forms with a large number of fields, this approach scales easily by using the name attribute to dynamically reference state keys.

With useState, you can effectively manage and update form data, ensuring the form’s behaviour remains consistent and intuitive. 

Using useReducer for Complex Form State

When managing complex forms with multiple fields or dependencies between inputs, useReducer is a better alternative to useState. It provides a structured way to manage state transitions by centralizing state updates in a reducer function. This approach simplifies handling interdependent fields or multiple form sections and keeps your code more maintainable.

When to Use useReducer

  • When updates depend on the current state or involve multiple fields.
  • When changes in one field affect the values or behaviour of another.
  • When your form grows beyond a handful of fields, and maintaining individual useState calls becomes unwieldy.
import React, { useReducer } from "react";

// Define the initial state of the form
const initialState = {
  personalInfo: {
    firstName: "",
    lastName: "",
  },
  contactInfo: {
    email: "",
    phone: "",
  },
  agreeToTerms: false,
};

// Define the reducer function
function formReducer(state, action) {
  switch (action.type) {
    case "UPDATE_FIELD":
      return {
        ...state,
        [action.section]: {
          ...state[action.section],
          [action.field]: action.value,
        },
      };
    case "TOGGLE_TERMS":
      return {
        ...state,
        agreeToTerms: !state.agreeToTerms,
      };
    default:
      return state;
  }
}

function FormWithReducer() {
  const [formState, dispatch] = useReducer(formReducer, initialState);

  const handleChange = (section, field, value) => {
    dispatch({ type: "UPDATE_FIELD", section, field, value });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log("Form Submitted:", formState);
  };

  return (
    <form onSubmit={handleSubmit}>
      <h3>Personal Information</h3>
      <div>
        <label htmlFor="firstName">First Name:</label>
        <input
          type="text"
          id="firstName"
          name="firstName"
          value={formState.personalInfo.firstName}
          onChange={(e) =>
            handleChange("personalInfo", "firstName", e.target.value)
          }
        />
      </div>
      <div>
        <label htmlFor="lastName">Last Name:</label>
        <input
          type="text"
          id="lastName"
          name="lastName"
          value={formState.personalInfo.lastName}
          onChange={(e) =>
            handleChange("personalInfo", "lastName", e.target.value)
          }
        />
      </div>

      <h3>Contact Information</h3>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formState.contactInfo.email}
          onChange={(e) =>
            handleChange("contactInfo", "email", e.target.value)
          }
        />
      </div>
      <div>
        <label htmlFor="phone">Phone:</label>
        <input
          type="tel"
          id="phone"
          name="phone"
          value={formState.contactInfo.phone}
          onChange={(e) =>
            handleChange("contactInfo", "phone", e.target.value)
          }
        />
      </div>

      <div>
        <label>
          <input
            type="checkbox"
            checked={formState.agreeToTerms}
            onChange={() => dispatch({ type: "TOGGLE_TERMS" })}
          />
          I agree to the terms and conditions
        </label>
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

export default FormWithReducer;

Example: Handle Form Sections That Depend on Each Other

  • If the user fills out "Contact Information," the "I agree to terms" checkbox becomes enabled.
  • The form cannot be submitted unless the checkbox is selected.

This interdependent behaviour is handled cleanly by useReducer. For instance, the agreeToTerms toggle is only enabled when the email or phone fields are not empty. You can add a condition in the formReducer for such requirements.

Tips

  • Keep all field-related logic in the reducer to make state transitions predictable and easier to debug.
  • Use descriptive action types (e.g., "UPDATE_FIELD", "TOGGLE_TERMS") for clarity.
  • Add validation or pre-fill functionality directly in the reducer without disrupting the component.

With useReducer, managing complex forms becomes a straightforward and scalable process, even when handling interdependencies or additional features like validation and conditional logic. 

Form Validation with Custom Hooks

Validation is an important part of handling forms, ensuring the data entered by users is accurate and complete. By using a custom hook, you can centralize validation logic, making it reusable and easy to integrate with multiple forms. This approach simplifies real-time validation while keeping your form components clean and focused.

Custom hooks allow you to encapsulate reusable validation logic, such as checking required fields or validating specific formats (e.g., email). By keeping validation separate from the main component, you improve maintainability and reduce code duplication. The useValidation hook will return validation status and error messages, which can be applied to input fields in real time.

Creating the useValidation Hook:
This hook checks if fields are valid and generates error messages dynamically.

import { useState } from "react";

function useValidation(initialValues, validationRules) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  const validate = (fieldName, value) => {
    const rule = validationRules[fieldName];
    if (!rule) return; // Skip if no rule exists for this field

    let error = "";
    if (rule.required && !value) {
      error = `${fieldName} is required.`;
    } else if (rule.pattern && !rule.pattern.test(value)) {
      error = `${fieldName} is invalid.`;
    }

    setErrors((prevErrors) => ({
      ...prevErrors,
      [fieldName]: error,
    }));
  };

  const handleChange = (event) => {
    const { name, value } = event.target;
    setValues((prevValues) => ({
      ...prevValues,
      [name]: value,
    }));
    validate(name, value);
  };

  return {
    values,
    errors,
    handleChange,
  };
}

export default useValidation;

Applying the Hook in a Form Component:
Here’s how to use useValidation to validate required fields and an email format.

import React from "react";
import useValidation from "./useValidation";

function FormWithValidation() {
  const validationRules = {
    name: { required: true },
    email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
    message: { required: false },
  };

  const { values, errors, handleChange } = useValidation(
    { name: "", email: "", message: "" },
    validationRules
  );

  const handleSubmit = (event) => {
    event.preventDefault();
    if (Object.values(errors).some((error) => error)) {
      console.log("Form has errors:", errors);
    } else {
      console.log("Form Submitted:", values);
    }
  };

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

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

      <div>
        <label htmlFor="message">Message:</label>
        <textarea
          id="message"
          name="message"
          value={values.message}
          onChange={handleChange}
        ></textarea>
        {errors.message && <p className="error">{errors.message}</p>}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

export default FormWithValidation;

Example: Check Required Fields and Email Formats

  • If the name field is empty, the hook adds an error message, "name is required."
  • If the email field doesn’t match the pattern, it shows "email is invalid."
  • Errors appear as soon as the user interacts with a field, improving user experience.

Tips

  • Add more validation rules like minLength, maxLength, or custom validation functions for specific fields.
  • Use CSS to highlight fields with errors and display error messages effectively.
.error {
  color: red;
  font-size: 0.85em;
}
input:invalid {
  border-color: red;
}

Use this hook across multiple forms by tweaking the validationRules for each form's specific requirements.

Handling Dynamic Form Fields

Dynamic form fields allow users to add or remove inputs as needed, such as entering multiple addresses, phone numbers, or other repeatable data. Managing dynamic fields in React requires updating the state to reflect changes in the number or values of the inputs.

How to Add and Remove Fields Dynamically

To manage dynamic fields:

  1. Store repeatable data (e.g., multiple addresses) in an array within the state.
  2. Append a new entry to the array when the user clicks a button to add a field.
  3. Remove the corresponding entry from the array when the user deletes a field.
  4. Update the value in the array when the user edits the input.
import React, { useState } from "react";

function DynamicFieldsForm() {
  const [addresses, setAddresses] = useState([{ address: "" }]);

  // Handle adding a new field
  const addAddressField = () => {
    setAddresses((prev) => [...prev, { address: "" }]);
  };

  // Handle removing a field
  const removeAddressField = (index) => {
    setAddresses((prev) => prev.filter((_, i) => i !== index));
  };

  // Handle updating a field
  const handleAddressChange = (index, value) => {
    setAddresses((prev) =>
      prev.map((item, i) =>
        i === index ? { ...item, address: value } : item
      )
    );
  };

  // Handle form submission
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log("Submitted Addresses:", addresses);
  };

  return (
    <form onSubmit={handleSubmit}>
      <h3>Dynamic Address Fields</h3>

      {addresses.map((item, index) => (
        <div key={index} style={{ marginBottom: "10px" }}>
          <input
            type="text"
            placeholder={`Address ${index + 1}`}
            value={item.address}
            onChange={(e) => handleAddressChange(index, e.target.value)}
          />
          <button
            type="button"
            onClick={() => removeAddressField(index)}
            style={{ marginLeft: "5px" }}
          >
            Remove
          </button>
        </div>
      ))}

      <button type="button" onClick={addAddressField}>
        Add Address
      </button>
      <br />
      <button type="submit">Submit</button>
    </form>
  );
}

export default DynamicFieldsForm;

Example: Add Multiple Addresses

  • Clicking "Add Address" appends a new input field to the list.
  • Clicking "Remove" deletes the corresponding input field from the list.
  • Changes to any input are immediately reflected in the addresses array in the state.

Tips

  1. Use the useValidation hook (from Step 4) to validate dynamic inputs like ensuring no field is empty.
  2. Add a label to indicate which input corresponds to which entry (e.g., "Primary Address", "Secondary Address").
  3. Extend this approach to handle other types of repeatable inputs, such as phone numbers or contacts.

Submitting the Form and Resetting State

Once the form is complete, the final step is to handle submission and reset the form state. Submitting involves capturing the current state of the form, validating the data if necessary, and sending it to a server or API. Resetting the state clears the form for future use or prepares it for new data entry.

Walkthrough of Handling Form Submission

  • Before submission, validate the form data to ensure all required fields are properly filled.
  • Use a POST request with fetch or axios to send the data to an endpoint.
  • After successful submission, reset the form state to its initial values to clear the inputs.
import React, { useState } from "react";

function SubmitAndResetForm() {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    message: "",
  });

  const [successMessage, setSuccessMessage] = useState("");

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

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

    // Validate the form
    if (!formData.name || !formData.email) {
      alert("Name and Email are required!");
      return;
    }

    try {
      // Submit data to an API endpoint
      const response = await fetch("https://example.com/api/submit-form", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(formData),
      });

      if (!response.ok) {
        throw new Error("Failed to submit the form");
      }

      setSuccessMessage("Form submitted successfully!");
      // Reset form state
      setFormData({
        name: "",
        email: "",
        message: "",
      });
    } catch (error) {
      console.error("Error submitting form:", error);
      setSuccessMessage("Failed to submit the form. Please try again.");
    }
  };

  return (
    <div>
      <h3>Submit and Reset Form</h3>
      <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>

        <div>
          <label htmlFor="message">Message:</label>
          <textarea
            id="message"
            name="message"
            value={formData.message}
            onChange={handleChange}
          ></textarea>
        </div>

        <button type="submit">Submit</button>
      </form>

      {successMessage && <p>{successMessage}</p>}
    </div>
  );
}

export default SubmitAndResetForm;

Example: Send Form Data to an API Endpoint

  • The form checks for required fields (name and email) before submitting.
  • Data is sent to a placeholder API endpoint (https://example.com/api/submit-form) using fetch.
  • Response Handling:
    • On success: Display a success message and reset the form state.
    • On failure: Display an error message and retain the form data for corrections.

Tips

  1. Replace the placeholder endpoint with your actual API endpoint. For local testing, you can use mock APIs like JSONPlaceholder.
  2. Use asynchronous requests for smoother form submissions and handle errors gracefully with try/catch.
  3. Modify the reset behaviour based on your requirements (e.g., retain certain values after submission).

Bonus: Using useForm Libraries with Hooks

While creating forms manually with React Hooks provides full control, libraries like react-hook-form can simplify form handling, especially for more complex use cases. These libraries abstract away much of the repetitive work involved in managing state, validation, and submission, enabling you to focus on your application logic.

Why Use a Library?

  • Libraries like react-hook-form handle common tasks like managing form state and validations with less code.
  • Unlike traditional React state, libraries optimize re-renders, ensuring only the affected fields update.
  • They include built-in utilities for validation, dynamic fields, and integrations with external schema validators like Yup or Zod.

Example: Setting Up a Form with react-hook-form

Here’s how to create a simple form using react-hook-form:

npm install react-hook-form
import React from "react";
import { useForm } from "react-hook-form";

function ReactHookFormExample() {
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm();

  const onSubmit = (data) => {
    console.log("Form Data:", data);
    reset(); // Reset the form after submission
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="name">Name:</label>
        <input
          id="name"
          {...register("name", { required: "Name is required" })}
        />
        {errors.name && <p className="error">{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: "Invalid email format",
            },
          })}
        />
        {errors.email && <p className="error">{errors.email.message}</p>}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

export default ReactHookFormExample;

Advanced Features

Integrate Yup for declarative validation rules.

npm install @hookform/resolvers yup
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";

const schema = yup.object().shape({
  name: yup.string().required("Name is required"),
  email: yup.string().email("Invalid email").required("Email is required"),
});

const { register, handleSubmit, errors } = useForm({
  resolver: yupResolver(schema),
});
  • Easily handle repeatable fields (e.g., adding/removing inputs) with useFieldArray from react-hook-form.
  • Automatically map validation errors to specific fields.

Resources and Documentation

Need a Helping Hand with Your Project?

Whether you need continuous support through our Flexible Retainer Plans or a custom quote, we're dedicated to delivering services that align perfectly with your business goals.

Please enter your name

Please enter your email address

Contact by email or phone?

Please enter your company name.

Please enter your phone number

What is your deadline?

Please tell us a little about your project