Different Ways to Handle Form Submission in React

[object Object]
Sara MitevskaMay 28th, 2024
May 28th, 2024 10 minutes read
https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjTDg1qWISJHBFUyrBoRpsdTaXK4cy2OdDR0s-eC37UNDtEy_Dq5pYv5ymBVhoD3ue75P41Q9DnZYMaacrbFovaasl50MVLuaDiBPamTjaHVBeij-9RG7XgZSYrPsLJ-6e7MFUhwL30m1JuLxGBaf2XUAvpKnIB7tiGCPCeI_8NDl2TgUBZUugIlc3M90nI/w640-h640/6518027.webp
different ways to submit form

Much like HTML, in React, forms are also used to allow user interaction within web pages. Adding a form in React is as simple as adding any other element:

  <form>
    {/* Form fields go here */}
  </form>

However, the default behaviour of form submission in React typically isn't what we want. Instead, we aim to override this default behaviour and allow React to manage the form. This gives us more control over how the form data changes and how it is submitted.

There are two common approaches used in React when building forms: Controlled and uncontrolled forms.

Controlled forms

In a controlled form, the values of the form fields are controlled by the React component, usually stored in the component's state. This means that every change of a value in the field triggers a state update, which then re-renders the component with the updated value.

Controlling form data using the useState hook

The first approach that usually comes to mind when building controlled forms is using the component's state to store the values of the fields. Specifically, the useState hook (in functional components). Here is a simple example of how this looks within a small form with one input field:

  import { useState } from "react";

  function ControlledForm() {
    const [name, setName] = useState("");

    return (
      <form>
        <label>Enter your name:
          <input
            type="text" 
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
        </label>
      </form>
    )
  }

  export default ControlledForm;

This approach might seem fine for simple forms, but as a form grows, the code can get more and more complex. Imagine if there are 15 fields in a form. There will have to be a useState hook for each of the 15 form fields. This will make maintaining the state and code harder.

In situations that require large and complex forms, using a different approach than the component's state is a better idea. Such an approach is using React's useReducer hook.

Controlling form data using the useReducer hook

In React, a reducer is a function that manages complex state logic in a more organized and predictable way. It takes the current state and an action and calculates the new state based on that action. It's like having a set of instructions for how to update your state depending on what action is performed.

The useReducer is a React hook that allows you to use reducers in functional components.

Let's improve the previous example to use useReducer instead of useState. First, we're going to define the initial state. Since we only have one field for now, we'll add a new state property values that will be an object with one property: name, which is the name of our form field.

In a new file that will hold the reducer function, we'll define and export our initial state:

  export const initialState = {
    values: {
      name: ''
    }
  };

Next, we will define the reducer function. Since this is the simplest example, we'll add only one action that will describe what we want to change in the state. Let's name the action SET_VALUE. The task of this action will be to update the specific value of the 'name' field.

  export function formReducer(state, action) {
    switch (action.type) {
      case 'SET_VALUE':
        return {
          ...state,
          values: {
            ...state.values,
            [action.payload.field]: action.payload.value,
          },
        };
      default:
        return state;
    }
  }

If you're not familiar with how a reducer works, here is a brief explanation of the code:

The formReducer function takes two arguments:
  • state (the current state of the form)
  • action (an object describing what changes to make to the state)
switch (action.type): This checks what type of action is being performed.

case 'SET_VALUE': If the action type is 'SET_VALUE', the reducer will update the values object in the state.

return {...state, ...}: This line creates a new state object by copying the existing state.

values: {...state.values, ...}: This line creates a new values object by copying the current values from the state.

[action.payload.field]: action.payload.value: This updates a specific field in the values object. The field to update and the new value come from action.payload (the action payload is passed when the action is triggered from the form component. You'll see this later in this post).

default: If the action type is not 'SET_VALUE', the reducer just returns the current state without making any changes.

Next, we'll need to update the form component to use our new reducer to manage the form data instead of the useState hook.

  import { useReducer } from 'react';
  import { formReducer, initialState } from './reducer';

  function ControlledForm() {
    const [state, dispatch] = useReducer(formReducer, initialState);

    return (
      <form>
        <label>Enter your name:
          <input
            type="text" 
            value={state.values.name}
            onChange={(e) =>
              dispatch({
                type: 'SET_VALUE',
                payload: { field: 'name', value: e.target.value },
              })
            }
          />
        </label>
      </form>
    )
  }

  export default ControlledForm;

Now, let's break down the code. First, we import the reducer and initial state we defined in the previous step and pass them into the useReducer hook.

The useReducer hook returns an array with two elements:
  • state: will always hold the current state of the component
  • dispatch: a function that is called to update the state. When dispatch is called with an action object, the formReducer function will process this action and return the new state
And finally, in the onChange method, we call the dispatch function with the action object. The action object contains two properties:
  • type: the type of the action we want to trigger. In this case, it's the SET_VALUE action that we added in our reducer.
  • payload: an object containing the data we want to update in our state. In this case, we're passing the name of the field we want to update and the value of the field that we want to update it to.

That's it! We've just updated our form to use useReducer, and now we can add a lot more form fields while keeping the code clean and easily maintainable.

We can also add new actions in our reducer, for example: an action that will keep info about which fields are touched, or an action that will handle errors in our form, etc.

Uncontrolled forms

Uncontrolled forms rely on the browser to manage the form data. In this approach, form fields maintain their own state, and React components only need to access the DOM elements to retrieve the data when needed.

Uncontrolled form using the useRef hook

When building uncontrolled forms, the useRef hook is often used to access the value of each form field. useRef is a hook that returns a mutable ref object. This object has a current property that can be used to store a value or a reference to a DOM element. Unlike state, updating a ref does not cause the component to re-render.

  import { useRef } from 'react';

  function UncontrolledForm() {
    const nameRef = useRef();

    return (
      <form onSubmit={(e) => {
        e.preventDefault();
        alert(nameRef?.current.value);
      }}>
        <label>Enter your name:
          <input ref={nameRef} type="text" />
        </label>
      </form>
    );
  }

  export default UncontrolledForm;

This will work great for simpler forms. However, just like when using useState, this approach also becomes hard to maintain once the form grows and a lot more fields are needed. That's why I'd always recommend using the magical FormData object.

Uncontrolled form using the FormData object

The FormData object is a built-in JavaScript object that provides a way to easily construct a set of key/value pairs representing form fields and their values. It is commonly used to send form data. It is also important to note that FormData will only use input fields that use the name attribute.

Let's see this in code. First, we'll take the previous example and replace the input ref with a new ref for our form. We'll need this in order to use the FormData object on our form element.

  import { useRef } from 'react';

  function UncontrolledForm() {
    const formRef = useRef();

    ...
  }

  export default UncontrolledForm;

Next, we'll define a new function that will handle the form submission.

  import { useRef } from 'react';

  function UncontrolledForm() {
    const formRef = useRef();

    const handleSubmit = (e) => {
      // prevent the default submit behavior that will refresh the page
      e.preventDefault();

      // construct the key/value pairs from the form element.
      const formData = new FormData(formRef.current);

      const formValues = {};
      // map each key/value pair to a new object
      formData.forEach((value, key) => {
        formValues[key] = value;
      });

      alert(JSON.stringify(formValues, null, 2));
    };

     ...
  }

  export default UncontrolledForm;

When you create a new FormData object and pass a form element to it, it automatically collects all the data (field values) from that form.

The complete code looks like this:

  import { useRef } from 'react';

  function UncontrolledForm() {
    const formRef = useRef();

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

      const formData = new FormData(formRef.current);

      const formValues = {};
      formData.forEach((value, key) => {
        formValues[key] = value;
      });

      alert(JSON.stringify(formValues, null, 2));
    };

    return (
      <form ref={formRef} onSubmit={handleSubmit}>
        <label>Enter your name:
          <input name="name" type="text" />
        </label>
        <button type="submit">Submit</button>
      </form>
    );
  }

  export default UncontrolledForm;

That's it. You can now add any fields you like, and they will be automatically picked up by the FormData object as long as they have the name attribute.

Uncontrolled form using the new React form action attribute

Another way to build uncontrolled forms is using the new React extension to the form element, specifically the improved action attribute. Unlike the standard action attribute on the form element, the new improvements allow us to pass a URL or a function as an action. When a function is passed as action, the function will handle the form submission. It is called with a single argument containing the form data of the submitted form. The action prop can also be overridden by a formAction attribute on a <button>, <input type="submit">, or <input type="image"> component.

Let's see how it looks:

  function UncontrolledFormActionAttribute() {
    const handleSubmit = (formData) => {
      const name = formData.get('name');
      alert(name);
    };

    return (
      <form action={handleSubmit}>
        <label>Enter your name:
          <br />
          <input name="name" type="text" />
        </label>
        <button type="submit">Submit</button>
      </form>
    );
  }
  export default UncontrolledFormActionAttribute;
Note: At the moment of writing this post, React’s extensions to <form> are currently only available in React’s canary and experimental channels.

Which Approach to use

When deciding between controlled and uncontrolled forms, consider the complexity and requirements of your application. Controlled forms are generally recommended for most use cases due to the precise control they offer over form data. They are particularly useful when you need to validate form input, control complex user interactions, or synchronize form state with other parts of your application. On the other hand, uncontrolled forms can be a better choice for simple forms or when integrating third-party libraries where direct access to form elements is needed. They are easier to set up and require less boilerplate code.

For a balanced approach, you can use the FormFusion library, which combines the benefits of both methods. FormFusion uses the performant uncontrolled forms approach and takes the burden of validation, error handling, and other complexities off your hands. It also offers a controlled version using the useReducer hook under the hood, making it suitable for applications hat require more control over the form data.

Ultimately, for large-scale applications that require more control over the form data, using FormFusion with its controlled version is preferable, while for simpler forms or when additional control over the form data is not required, the uncontrolled forms version provided by FormFusion might be a better choice.


The code from this post can be found on Github.