React 19 features for better form handling

Exploring React 19 Features: New Hooks and Actions for Better Form Handling

React 19 introduces some interesting tools that are supposed to make handling forms easier, cleaner and more performant. Let's take a look at them and check whether that's true.

Table of contents

    In April this year, the React team finally released highly anticipated version 19. For the time being, it’s still in the Release Candidate (RC) stage, so it's not recommended for production use. Yet, it's ready to test, so we can familiarize ourselves with new features before using it widely.

    Among the revolutionary additions, such as React Compiler which makes memoization as we know it obsolete, and React Server Components, which let you write components running on the server, there are also smaller but valuable ones.

    Quite a few of them may change how we create forms in React. New hooks like useActionState, useFormStatus, and useOptimistic, along with React Server Actions (which integrate with React Server Components), offer significant benefits here. We'll inspect them individually, but first, we should create the basic form, which we'll refactor with them.

    Setting up React 19 for testing

    The project can be set up to test new React 19 features using Vite. For that, type the following commands in your terminal:

    npm create vite@latest react-form-testing
    cd react-form-testing
    npm install react@beta react-dom@beta

    After that just type in your project's directory npm install and npm run dev, and you should be ready to go with your app!

    Creating the basic form component

    Let's start by making a simple form, like we did in earlier versions of React. Here's an example:

    import { useState } from "react";
    
    const initialFormData = {
      name: "",
      email: "",
      password: "",
    };
    
    const BasicForm = () => {
      const [formData, setFormData] = useState(initialFormData);
      const [submittedData, setSubmittedData] = useState(initialFormData);
      const [loading, setLoading] = useState(false);
    
      const handleChange = (e) => {
        const { name, value } = e.target;
        setFormData({
          ...formData,
          [name]: value,
        });
      };
    
      const handleSubmit = (e) => {
        e.preventDefault();
        setLoading(true);
    
        // Simulate an API call
        setTimeout(() => {
          setSubmittedData(formData);
          setLoading(false);
          setFormData(initialFormData);
        }, 2000);
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <div>
            <label>Name:</label>
            <input
              type="text"
              name="name"
              value={formData.name}
              onChange={handleChange}
            />
          </div>
    
          <div>
            <label>Email:</label>
            <input
              type="email"
              name="email"
              value={formData.email}
              onChange={handleChange}
            />
          </div>
    
          <div>
            <label>Password:</label>
            <input
              type="password"
              name="password"
              value={formData.password}
              onChange={handleChange}
            />
          </div>
    
          <button type="submit" disabled={loading}>
            Submit
          </button>
    
          <ul>
            {submittedData &&
              Object.entries(submittedData).map(([name, value]) => (
                <li key={name}>
                  <strong>{name}:</strong> {value}
                </li>
              ))}
          </ul>
        </form>
      );
    };
    
    export default BasicForm;
    

    As we can see, we keep the form data, submitted data and loading state here with the React hook useState and manage the form with the help of two functions - submit and change handlers. The handleSubmit function is where we also would send data to our server. As we focus on the front-end part of form submission, I simulated the network request with the setTimeout function.

    For now, the form doesn't look very messy, but still, it can seem quite wordy. With the addition of more fields, validation, and maybe some additional functionalities, it can get bigger and harder to grasp. No wonder we used to utilize third-party libraries such as React Hook Form and Formik. New React 19 hooks may change that.

    New React hooks: useActionState hook

    Let's start with the useActionState. This hook, while evolved from earlier useFormState (not to be confused with useFormStatus!) and especially helpful in managing forms, can also be used in other cases. But for now, we'll stick with the form example to keep it simpler.

    As we can see in the React documentation, the hook accepts three parameters:

    • the fn async function that triggers with a form submission (in our case it'll be the handleSubmit function)
    • the initialState of the form data
    • the optional permalink pointing to the URL of the page the form modifies

    As a result, the hook's return value is the array with:

    • the latest form state
    • the formAction function, which should be passed to the form's action or formAction prop
    • the boolean pending state variable representing the pending form submission
    const [state, formAction, pending] = useActionState(fn, initialState, permalink?);

    Let's see how using it can make our form submission simpler. Here's our refactored component:

    import { useActionState } from "react";
    
    const initialFormData = {
      name: "",
      email: "",
      password: "",
    };
    
    const NewForm = () => {
      const handleSubmit = async (_previousState, formData) => {
        // Simulate an API call
        await new Promise((resolve) => setTimeout(resolve, 2000));
        return Object.fromEntries(formData);
      };
    
      const [state, formAction, isPending] = useActionState(
        handleSubmit,
        initialFormData
      );
    
      return (
        <form action={formAction}>
          <div>
            <label>Name:</label>
            <input type="text" name="name" />
          </div>
    
          <div>
            <label>Email:</label>
            <input type="email" name="email" />
          </div>
    
          <div>
            <label>Password:</label>
            <input type="password" name="password" />
          </div>
    
          <button type="submit" disabled={isPending}>
            Submit
          </button>
    
          <ul>
            {state &&
              Object.entries(state).map(([name, value]) => (
                <li key={name}>
                  <strong>{name}:</strong> {value}
                </li>
              ))}
          </ul>
        </form>
      );
    };
    
    export default NewForm;
    

    As you can see, the component is considerably smaller. We don't need to use controlled input fields or store the form data and the loading state in useState, as all these are done automatically. And what if your app's structure gets bigger and more complicated, e. g. you'll add the separate Button component and put it down the component tree? Here's useFormStatus does its trick.

    New React hooks: useFormStatus hook

    Let's see this in action. Here's our new Button component:

    import { useFormStatus } from "react-dom";
    
    const Button = () => {
      const { pending } = useFormStatus();
    
      return (
        <button type="submit" disabled={pending}>
          Submit
        </button>
      );
    };
    
    export default Button;
    

    We don't need to pass any props to it, and it still behaves as expected. That's because useFormStatus serves as a context to its parent form without all the boilerplate necessary to utilize React.Context. This way you can avoid the prop drilling when your components start to grow. And all this with a minimal amount of code! Apart from pending property, you also get other ones:

    const { pending, data, method, action } = useFormStatus();
    

    Data represents the data of the parent form, method - the HTTP method used to submit the form, and action the function passed to the form's action prop. Such broad context allows us to break our form into more granular components without losing simplicity.

    New React hooks: useOptimistic hook

    The final hook introduced in the new version of React that can be beneficial in form submissions is useOptimistic. It tackles the problem of optimistic updates. Sometimes, you want to show the sent value before the request finishes. How to implement it? We'll try to use it to show data below our form just after the user submits it. Here's the modified NewForm component:

    import { useActionState, useOptimistic } from "react";
    import Button from "./Button";
    
    const initialFormData = {
      name: "",
      email: "",
      password: "",
    };
    
    const NewForm = () => {
      const handleSubmit = async (_previousState, formData) => {
        // Simulate an API call
        setOptimisticFormData(Object.fromEntries(formData));
        await new Promise((resolve) => setTimeout(resolve, 2000));
        return Object.fromEntries(formData);
      };
    
      const [state, formAction] = useActionState(handleSubmit, initialFormData);
      const [optimisticFormData, setOptimisticFormData] = useOptimistic(state);
    
      return (
        <form action={formAction}>
          <div>
            <label>Name:</label>
            <input type="text" name="name" />
          </div>
    
          <div>
            <label>Email:</label>
            <input type="email" name="email" />
          </div>
    
          <div>
            <label>Password:</label>
            <input type="password" name="password" />
          </div>
    
          <Button />
    
          <ul>
            {state &&
              Object.entries(optimisticFormData).map(([name, value]) => (
                <li key={name}>
                  <strong>{name}:</strong> {value}
                </li>
              ))}
          </ul>
        </form>
      );
    };
    
    export default NewForm;
    

    What's the difference between maintaining an optimistic state and holding the current state in the useState hook? If something goes wrong, e. g. the async function returns an error, you can switch back to the previous state without adding the state update with useState. You can check it by modifying the handleSubmit function like this:

      const handleSubmit = async (previousState, formData) => {
        setOptimisticFormData(Object.fromEntries(formData));
        // Simulate an API call
        try {
          await new Promise((_resolve, reject) =>
            setTimeout(() => reject(new Error("Failed to submit form")), 2000)
          );
        } catch (error) {
          console.error(error);
          return { ...previousState };
        }
        return Object.fromEntries(formData);
      };
    

    At first, with a pending update, typed values are shown below, but when the request fails, the state reverts to the previous one.

    React Server Actions

    While I'm sure the presented examples already show the possibilities of new React hooks, everything gets even more interesting when we introduce the concept of React Actions and how it finds its place in the paradigm of React Server Components.

    In a new version of React the function that uses async transitions are called Actions. An example is a function passed to useActionState, along with the one we're using as a callback in the startTransition function (which I described in the previous article). As we learned we can use them inside client components, but if we decide to incorporate the RSC (React Server Components) architecture, we discover even more new possibilities.

    While we're accustomed to React components being rendered as HTML on the client side (in the browser), this paradigm has shifted. With new features, we have two component types to utilize - classic Client Components and Server Components. The latter not only lets us generate our HTML for static pages beforehand but also simplifies the way we fetch our data. Now, instead of writing complicated API endpoints or using querying tools such as GraphQL, we can query our database just in React components. The functions we use for these are called Server Actions. To use it, the "use server" directive is needed, as in the following example:

      const handleSubmit = async (_previousState, formData) => {
        "use server";
    
        setOptimisticFormData(Object.fromEntries(formData));
        // Simulate an API call
        await new Promise((resolve) => setTimeout(resolve, 2000));
        return Object.fromEntries(formData);
      };
    

    When you define the function with this directive, the SRR framework will execute its reference on the server and return the result to the component. Notice that this works for server components. If you use a client component, you can still utilize it, but with slightly different syntax. You should put the function in a separate file with the "use server" directive at the top and import it to your component:

    "use server";
    
    export async function handleSubmit(_previousState, formData) {
      // Simulate an API call
      await new Promise((resolve) => setTimeout(resolve, 2000));
      return Object.fromEntries(formData);
    }
    

    Of course, it's only a tiny part of the new RSC architecture, which is still in progress and possibly will offer even more features in the future. Still, with it, we can gain insight into the direction React is heading. As can be seen, it's putting more and more focus on the server side intending to reduce the size of client-side JavaScript bundles. For more information, I encourage you to check the documentation on the topic and follow React's canary releases to try out new features.

    Summary

    The new React features show many new possibilities, which are especially useful in managing forms but go beyond that. While most of them shouldn't be used in production yet, there is much to learn and evaluate before determining if our projects can utilize them when they become stable. I think that my test proved that new hooks features may be beneficial in making form code simpler and more intuitive. Also, with the addition of RSC architecture, we'll be able to make our apps more performant. So, there is something to look forward to for sure!

    FAQ

    What are the main new features of React 19?

    React 19 introduces several notable features such as new hooks like useActionState, useFormStatus, and useOptimistic, which simplify form handling. It also integrates React Server Components and React Server Actions.

    How does the useActionState hook improve form handling?

    useActionState reduces form management complexity by handling form submission states and asynchronous actions without requiring manual state management or controlled inputs.

    What is the purpose of the useFormStatus hook?

    The useFormStatus hook simplifies communication between components within a form, allowing them to access the form’s state without passing props, thus avoiding prop drilling.

    How does the useOptimistic hook benefit user experience?

    The useOptimistic hook allows developers to implement optimistic updates, showing users their input immediately, even before the server confirms the data submission.

    What are React Server Components (RSC) in React 19?

    React Server Components allow parts of an application to be rendered on the server, reducing the size of client-side JavaScript and simplifying data fetching directly within components.

    How does React 19 change form validation?

    With the new hooks, React 19 can handle form validation more cleanly by managing states and transitions asynchronously, reducing the need for third-party libraries like Formik or React Hook Form.

    What is the role of React Server Actions?

    React Server Actions are functions that use the "use server" directive to process data on the server, improving performance by reducing client-side computation.

    Is React 19 ready for production use?

    As of now, React 19 is still in the Release Candidate (RC) stage and is not recommended for production use until further stability is confirmed.

    Curiosum React Developer Marta
    Marta Habdas React Developer

    Read more
    on #curiosum blog