How to manage forms natively in React 19

Omri levi
Nielsen-TLV-Tech-Blog
9 min readApr 9, 2024

React 19 is on its way with some cool updates. Though we won’t be getting the new compiler yet, the React team is introducing some new form management hooks (that are already utilized in Next.js 14) called useFormState and useFormStatus

We’re here to explore a native alternative to the widely used react-hook-form,so if you're itching to streamline your form game in React's newest environment, stick around. We're going to set up a project, build a custom useForm hook, and play around with dynamic fields and submission states. Let’s get started!

Project setup

For simplicity, we’ll use Vite to bootstrap the project and install all required dependencies. As the first step, use Vite to initialize a new react project:

npm create vite@latest

Vite will then ask you to select a framework. Select React and after that TypeScript when prompted to select a variant.

Now that the project is initialized, let’s install the required dependencies with the command:

npm install 

We’ll need to switch to React’s Canary channel to access the latest Hooks, run this line in the terminal:

npm install react@canary react-dom@canary

We’ll also adjust our TypeScript configuration to align with these Canary versions. To do so, go to vite-env.d.ts and change the contents of that file to the following:

/// <reference types="vite/client" />
/// <reference types="react/canary" />
/// <reference types="react-dom/canary" />

Once everything is installed, a quick npm run dev will get our development server running:

npm run dev

This will start the React server. You should see the following screenshot in your browser:

Getting started with our project

Now it’s time to write some code. We’ll start simple: a form component that gathers user input. Think of a classic contact form. It will have a few input fields (like name, email) and a submit button. Here, we’re building the foundation for our custom hook, setting the stage to incorporate the beloved functionalities of react-hook-form later on.

Building our contact form

First things first, we need to create a simple form component with two fields — user and email. Create a file called contact-form.tsx and paste the following code:

// contact-form.tsx
import React from "react";

const ContactForm: React.FC = () => {
return (
<form>
<input name="name" />
<input name="email" />
<input type="submit" />
</form>
);
};

export default ContactForm;
// Note: Please remember to import this component in your App.js

Adding React’s useFormState

At this stage, we have a basic form that is self managed. Now it’s time to add the new useFormState hook and see how it works together.

The useFormState hook in React is designed to manage form state efficiently. It takes a function (fn) and an initialState as parameters. fn is executed upon form submission or button press, receiving the previous state and any form action arguments. initialState sets the initial form state, which is updated based on fn's return value. In this article we won’t be covering server side or server components functionality, and will only handle client side validations.

const [state, formAction] = useFormState(fn, initialState);

Let’s copy the code above and start to implement it into our ContactForm component.

// contact-form.tsx
import React from 'react';
import { useFormState } from 'react-dom';

interface FormState {
name: string;
email: string;
}

const handleSubmit = async (
previousState: FormState | undefined,
formData: FormData
): Promise<FormState> => {
// The previousState variable contains the last saved form state
console.log('previous saved state ', previousState);
// Use get to extract a value from a FormData object
const name = formData.get('name');
const email = formData.get('email');
// The returned value will become our new formState
return { name, email };
};

const ContactForm: React.FC = () => {
const [formState, formAction] = useFormState(handleSubmit, {
name: '',
email: '',
});

return (
<>
formState: {JSON.stringify(formState)}
<form action={formAction}>
<input name='name' />
<input name='email' />
<input type='submit' />
</form>
</>
);
};

export default ContactForm;

Here’s an explanation of the code block above:

  • We pass a handler function called handleSubmit which receives two parameters — the old form state, and the new form values as a FormData object. the value it returns becomes the new form state, in our case the submitted name and email, however we can return everything we want, for example a status indication of success or failure, or a message to show to the user.
  • A second parameter with initial state with empty name and email properties. To be clear their values will not be passed down as initial values to the appropriate field
  • As a result, useFormState will now return two variables: formState, and formAction which we attach to our form like so: <form action={formAction}> and will be executed on submit (in our case we’ll use it like onSubmit property).
  • Let’s view what is printed by JSON.stringify(formState) before and after submit:

Creating a custom useForm hook

Now it’s time to take our spaghetti code above and turn it into a generic reusable hook.

Create a file called use-form.tsx . Let’s break the following code down:

  • useForm function receives a handleSubmit function that gets invoked just like before, and an initial state. For Typescript reusability we’re passing in the type T.
  • Since formData is difficult to work with we’re going to make it more manageable by converting it to an object.
  • onSubmit function is connected to our useFormState hook, and after manipulating (and later on validating) our data, invokes the handleSubmit function the user provides.
  • I’ve also added a submitCount property which we increment each time the onSubmit function is called.
// use-form.tsx
import { useState } from 'react';
import { useFormState, useFormStatus } from 'react-dom';

interface FormProps<T> {
formAction: () => void;
pending: boolean;
formState: T;
submitCount: number;
}

function useForm<T>(
handleSubmit: (data: T) => void,
initialState: T
): FormProps<T> {
const [formState, formAction] = useFormState<T>(onSubmit, initialState);
const { pending } = useFormStatus();
const [submitCount, setSubmitCount] = useState(0);

async function onSubmit(previousState: T, formData: FormData): Promise<T> {
setSubmitCount((count) => count + 1);
const fieldValues = Object.fromEntries(formData) as T;
await handleSubmit(fieldValues);
return fieldValues;
}

return {
formAction,
pending,
formState,
submitCount,
};
}

export default useForm;

Now let’s import our useForm inside our ContactForm component:

// contact-form.tsx
import React from 'react';
import useForm from './use-form';

interface FormState {
name: string;
email: string;
}

const handleSubmit = async (
data: FormState // formData is now a regular data
) => {
// here we can interact with the server or do our own check of the data
console.log('formData', data);
};

const ContactForm: React.FC = () => {
const { formState, formAction } = useForm<FormState>(handleSubmit, {
name: '',
email: '',
});

return (
<>
formState: {JSON.stringify(formState)}
<form action={formAction}>
<input name='name' />
<input name='email' />
<input type='submit' />
</form>
</>
);
};

export default ContactForm;

Currently handleSubmit doesn’t do too much, you can add server-side validation or manipulate the data to return a new state.

Adding register function

Now I’m sure you’re telling yourself, what if I want real-time form validation and display feedback to the user before submitting the form.

react-hook-form has a cool method called register which accepts a field name and adds onChange , onBlur and name properties to the input component. This will allow us to store a touched fields state, validate them in real-time and provide information before form submission.

We’ll add some local states inside use-form.tsx to store this information, formData is basically a live representation of formState , which we know gets updated only after submit.

  const [touched, setTouched] = useState({});
const [formData, setFormData] = useState(initialState);

The register function adds onBlur and onChange event handlers:


// use-form.tsx - inside useForm hook

const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};

const handleBlur = (event: ChangeEvent<HTMLInputElement>) => {
const { name } = event.target;
setTouched((prev) => ({ ...prev, [name]: true }));
};

const register = (name: keyof T) => ({
name,
onChange: handleChange,
onBlur: handleBlur,
});

Now we’ll replace formState with formData, and use the register function instead of passing a nameprop.contact-form.tsx should look like:

const ContactForm: React.FC = () => {
const { formData, register, formAction } = useForm<FormState>(handleSubmit, {
name: '',
email: '',
});

return (
<>
formState: {JSON.stringify(formData)}
<form action={formAction}>
<input {...register('name')} />
<input {...register('email')} />
<input type='submit' />
</form>
</>
);
};

Now formData shows live changes:

Adding form validation using Yup

yup is a popular schema builder for runtime value parsing and validation. Install it by running this command in the terminal

npm install yup

Add the necessary validationSchema parameter to useForm and store a local errors state. The validate function will run on form submission and for every field’s onBlur event:

// use-form.tsx
import { ObjectSchema } from 'yup';

function useForm<T>(
handleSubmit: (data: T) => void,
initialState: T,
validationSchema?: ObjectSchema<T>
): FormProps<T> {
const [errors, setErrors] = useState({});

const validate = async (data: T) => {
let errors = {};
try {
await validationSchema?.validate(data, { abortEarly: false });
} catch (err) {
if (err instanceof ValidationError) {
const newErrors: Record<string, string> = {};
err.inner.forEach((error) => {
if (error.path) newErrors[error.path] = error.message;
});
errors = newErrors;
}
}
return errors as Record<keyof T, string>;
};

async function onSubmit(previousState: T, formData: FormData): Promise<T> {
setSubmitCount((count) => count + 1);
const fieldValues = Object.fromEntries(formData) as T;
const errors = await validate(fieldValues);
setErrors(errors);
await handleSubmit(fieldValues, errors);
return fieldValues;
}

const handleBlur = (event: ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
if (validationSchema) {
// creating a schema only for the blurred field
const fieldSchema = validationSchema.pick([name]);
fieldSchema
.validate({ [name]: value })
.then(() => setErrors((currErrors) => ({ ...currErrors, [name]: '' })))
.catch((err) =>
setErrors((currErrors) => ({ ...currErrors, [name]: err.message }))
);
}
setTouched((prev) => ({ ...prev, [name]: true }));
};

Inside contact-form.tsx let’s add a validationSchema object and pass it as a third parameter to useForm hook. We can import the new errors object from useForm and show errors with errors.name or errors.email like:

// contact-form.tsx
import * as yup from 'yup';

const validationSchema = yup.object({
name: yup
.string()
.min(2, 'Too short!')
.max(50, 'Too long!')
.required('Required'),
email: yup.string().email('Invalid email').required('Required'),
});

const ContactForm: React.FC = () => {
const { formData, errors, register, formAction } = useForm<FormState>(
handleSubmit,
{
name: '',
email: '',
},
validationSchema
);

return (
<>
formState: {JSON.stringify(formData)}
<form action={formAction}>
<input {...register('name')} />
{errors.name && <div className='error-message'>{errors.name}</div>}
<input {...register('email')} />
{errors.email && <div className='error-message'>{errors.email}</div>}
<input type='submit' />
</form>
</>
);
}

We can now display validation errors in real time, or if we prefer, only if submitCount is greater than 1.

Bonus section

React also introduced a hook called useFormStatus which gives us access to the current submission status of our form. So, for example, you can get the current method that was used to submit the form- get | post , and the pending status to display a loading spinner and disable the submit button until completion. To do so just add these 2 lines of code to useForm :

// use-form.tsx

const { pending } = useFormStatus();

return {
pending,
// rest of props
};

Conclusion

Here is the source code for the complete project.

In this article, you’ve learned the fundamentals of React’s new useFormState hook, and built a solid custom hook for you to expand and tailor to your own needs. Integrating Yup for field validations provided us with a powerful pattern for real-time feedback, ensuring our forms are both user-friendly and technically sound. We’ve laid the groundwork for a potential native react-hook-form replacement!

Thanks for reading!

--

--

Omri levi
Nielsen-TLV-Tech-Blog

Frontend Developer Working as part of the Infra team, experienced with building design systems from scratch and providing micro frontends wrapper layer.