The React ecosystem is lush with libraries, articles, videos, and more resources for every web development topic you could imagine. However, over time, many of these resources become outdated with modern best practices.
Working on a complex dynamic form recently for an AI side project, and researching good guides to React forms, I realized that most of the resources about building forms are outdated, often by many years.
This article will explain modern best practices for building forms in React, how to build dynamic forms, how forms relate to React Server Components, and more. Finally, after gaining an understanding of those topics, I’ll conclude with an explanation of what I found lacking in other guides, and recommendations from my experience with React.
Controlled vs Uncontrolled
The key to understanding React forms is the idea of “controlled” vs “uncontrolled” inputs, which are two different methods of building forms in React.
Controlled forms store each input’s value in React state, and then set the value of each input on each re-render from that state. If another function updates the state, that will immediately be reflected in the components’ values. If your React logic does not render the form inputs, the values will still be maintained in your app’s state. Controlled inputs tend to give you more options in your React logic, such as complex, non-HTML-standard form validation as the user types (maybe checking password strength), code manipulation of the values of the inputs (such as hyphenating phone numbers as the user types).
They look something like this:
import React, { useState } from 'react';
function ControlledForm() {
const [value, setValue] = useState('');
const handleChange = (event) => {
setValue(event.target.value);
};
const handleSubmit = () => {
// no "submit event" param needed above ^
sendInputValueToApi(value).then(() => /* Do something */)
};
return (
<>
<input type="text" value={value} onChange={handleChange} />
<button onClick={handleSubmit}>Send</button>
</>
}
Note that it would be more semantically correct to wrap the input in a <form>
, and give the input a name, but it’s not functionally needed because the data is already saved in a state, so we don’t need a real onSubmit
event and our input doesn’t need to be accessed directly when the button is pressed.
There are a few downsides:
- You may not want to re-render the form every time the user types.
- You need to write a lot of code to manage complex forms, because you wind up with a lot of states as your form grows in size. That leads to boilerplate useState and state-setting, which expands your code.
- It’s harder to build dynamic forms where the number of fields is variable, because you can’t conditionally use hooks like useState. To fix this problem: (a) Your entire form state likely becomes a single, huge object, (b) Then all children components will re-render on every input change due to using an object as a state rather than primitives. (c) The only way around this, memoization, adds a lot more boilerplate.
This can lead to performance challenges with bigger forms like spreadsheets, tables, etc.
import React, { useState } from "react";
function CumbersomeForm() {
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
address: "",
// ... potentially many more individual properties
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prevState) => ({ ...prevState, [name]: value }));
};
return (
<>
<label>First Name:</label>
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleChange}
/>
<label>Last Name:</label>
<input
type="text"
name="lastName"
value={formData.lastName}
onChange={handleChange}
/>
<label>Email:</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
<label>Address:</label>
<input
type="text"
name="address"
value={formData.address}
onChange={handleChange}
/>
{/* ... potentially many more individual input fields */}
</>
);
}
On the other hand, uncontrolled inputs do not save the input values in React state. Instead, uncontrolled forms use the native, built-in <form>
functionalities of vanilla HTML and JavaScript to manage data. So, for example, instead of setting a state with the input’s value every time it’s changed, and then setting its value prop, the browser manages the input value. Our React component never needs or uses the values - when the component is rendered, React will add the onsubmit event listener to the form, and when the submit button is pushed, our handleSubmit function runs. Instead of using React state, it’s much closer to the way a plain HTML form would work without any JavaScript.
function UncontrolledForm() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const inputValue = formData.get('inputName');
sendInputValueToApi(inputValue).then(() => /* Do something */)
};
return (
<form onSubmit={handleSubmit}>
<input type="text" name="inputName" />
<button type="submit">Send</button>
</form>
);
}
A benefit of this approach is that there’s less boilerplate. Compare these two inputs:
// Controlled
const [value, setValue] = useState('');
const handleChange = (event) => {
setValue(event.target.value);
};
...
<input type="text" value={value} onChange={handleChange} />
// Uncontrolled
<input type="text" name="inputName" />
The difference is significant even with 1 input, but becomes even more clear with many more inputs. Compare the longer controlled form above with this one:
import React from "react";
function UncontrolledForm() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
for (let [key, value] of formData.entries()) {
console.log(`${key}: ${value}`);
}
};
return (
<form onSubmit={handleSubmit}>
<label>First Name:</label>
<input type="text" name="firstName" />
<label>Last Name:</label>
<input type="text" name="lastName" />
<label>Email:</label>
<input type="email" name="email" />
<label>Address:</label>
<input type="text" name="address" />
{/* ... potentially many more individual input fields */}
<button type="submit">Submit</button>
</form>
);
}
This form has less boilerplate than the controlled version, and we don’t need to manage multiple hook calls or a large complex state object. In fact, there is no state here at all. This form could have hundreds or thousands of children, and they will never cause each other to re-render. With this method, forms perform better, have less boilerplate, and are easier to reason about.
The downside of uncontrolled forms is that you don’t have direct access to each input’s value. This can make custom validation more difficult, and makes direct input value manipulation impossible.
Caveats and Compromises
(Don't) useRef
Many articles recommend using a ref on each input in uncontrolled forms instead of using new FormData()
which I believe is because the FormData API is lesser-known. However, it’s been a standard for almost a decade and has been supported by all major browsers for many years. I strongly recommend that you do not use a useRef call for each form input, as this introduces some of the same challenges and boilerplate as useState calls.
However, there are some use-cases where a ref can help.
- Focus Management - programmatically focus on form elements, especially when dealing with dynamic form fields.
function MyForm() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current.focus();
};
return (
<form>
<input ref={inputRef} type="text" />
<button type="button" onClick={focusInput}>
Focus Input
</button>
</form>
);
}
- Calling Methods on Child Components’ HTML Elements - for example, if your form is broken into subcomponents and you need to focus on one of them from your parent form, or a sibling of the subcomponent, you need to pass around a ref from the parent, like this:
const ChildComponent = React.forwardRef((props, ref) => (
<input ref={ref} type="text" />
));
function MyForm() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current.focus();
};
return (
<form>
<ChildComponent ref={inputRef} />
<button type="button" onClick={focusInput}>
Focus Input
</button>
</form>
);
}
- Other, non-form-specific ref use-cases - such as saving previous values with a useEffect, or measuring the physical size of an element like a textarea that a user can expand
Mixing Controlled and Uncontrolled
In many cases, you may need to control one or a few inputs. Manipulating a phone number as the user types it in is a great example. In those cases, even if you’re using an uncontrolled form, you can just use one controlled input. In that case, don’t use the state to access the input’s value in form submission - continue using new FormData(...)
- just use the state to manage the display of the input in question.
function MixedForm() {
const [phoneNumber, setPhoneNumber] = useState("");
const handlePhoneNumberChange = (event) => {
// Some function to format the phone number
const formattedNumber = formatPhoneNumber(event.target.value);
setPhoneNumber(formattedNumber);
};
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
for (let [key, value] of formData.entries()) {
console.log(`${key}: ${value}`);
}
};
return (
<form onSubmit={handleSubmit}>
<label>Name:</label>
<input type="text" name="name" />
<label>Email:</label>
<input type="email" name="email" />
<label>Phone Number:</label>
<input
type="tel"
name="phoneNumber"
value={phoneNumber}
onChange={handlePhoneNumberChange}
/>
<label>Address:</label>
<input type="text" name="address" />
<button type="submit">Submit</button>
</form>
);
}
// Helper function for phone number formatting (just an example)
function formatPhoneNumber(number) {
// Format the phone number as desired. This is just a basic example.
return number.replace(/\D/g, "").slice(0, 10);
}
Note: minimize states. In this example, you wouldn’t want a useState for saving the raw phone number, and a separate useState for saving the formatted phone number, with an effect to sync them - that would cause unnecessary re-renders.
Speaking of optimizing re-renders, we can also move the controlled input to its own component to minimize re-renders of the rest of the form.
const PhoneInput = () => {
const [phoneNumber, setPhoneNumber] = useState("");
const handlePhoneNumberChange = (event) => {
// Some function to format the phone number
const formattedNumber = formatPhoneNumber(event.target.value);
setPhoneNumber(formattedNumber);
};
return (
<input
type="tel"
name="phoneNumber"
value={phoneNumber}
onChange={handlePhoneNumberChange}
/>
);
};
function MixedForm() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
for (let [key, value] of formData.entries()) {
console.log(`${key}: ${value}`);
}
};
return (
<form onSubmit={handleSubmit}>
<label>Name:</label>
<input type="text" name="name" />
<label>Email:</label>
<input type="email" name="email" />
<label>Phone Number:</label>
<PhoneInput />
<label>Address:</label>
<input type="text" name="address" />
<button type="submit">Submit</button>
</form>
);
}
// Helper function for phone number formatting (just an example)
function formatPhoneNumber(number) {
// Format the phone number as desired. This is just a basic example.
return number.replace(/\D/g, "").slice(0, 10);
}
If you’re used to controlled inputs, then on a first glance at the code above, you might think, “how does the parent form know the value of the child phone input if it’s not passing in some state setter or a ref?” To understand that, remember that when the React code is rendered as HTML, the browser will only “see” the HTML for the form and the inputs inside, including the input which the PhoneInput renders - the structure of our React components has no impact on the functionality of our rendered HTML. Then, that input value will be included in the FormData like any other field in the form. This is the power of encapsulation and composition of components in React. We can localize the re-renders to the minimum-affected JSX, while the DOM all still comes together as native HTML.
This is also useful for building dynamic forms. If you were. managing a state object using controlled components, you'd need to add and remove properties manually when form fields were created or removed. On the other hand, with uncontrolled components, you can just render/unrender inputs with unique names, and their values will automatically appear in the FormData on-submit.
But wait… how do I validate input values in uncontrolled inputs?
If you thought of this, you’re not alone! When validation is required before submitting a form, React devs tend to jump to using controlled components with state.
Many developers don’t realize that you don’t always need React or custom JavaScript for these types of validations - in fact, the web platform has them built in as attributes on form inputs! Check out this page on MDN for the details: https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation
Without any JS, you can require input values, set length requirements, and set format requirements using regular expressions (AI tools like ChatGPT are great for helping with complex Regex)
Error Handling
On a related topic, devs tend to reach for controlled components when we need to show some validation errors on the client-side. However, instead of using controlled components and setting values on every input change, when the requirements allow it I would prefer to use uncontrolled components and manage error handling in my onSubmit function. This minimizes the amount of state and state updates in your forms. Maybe it looks like this:
function UncontrolledForm() {
const [errors, setErrors] = useState({});
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
let validationErrors = {};
// Custom validation: Ensure the email domain is "example.com"
const email = formData.get("email");
if (email && !email.endsWith("@example.com")) {
validationErrors.email = "Email must be from the domain example.com.";
}
if (formData.get("phoneNumber").length !== 10) {
validationErrors.phoneNumber = "Phone number must be 10 digits.";
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
} else {
// Handle the successful form submission, e.g., sending formData to a server
console.log(Array.from(formData.entries()));
setErrors({}); // Clear any previous errors
}
};
return (
<form onSubmit={handleSubmit}>
<label>Name:</label>
<input type="text" name="name" required />
{errors.name && <div className="error">{errors.name}</div>}
<label>Email (must be @example.com):</label>
<input type="email" name="email" required />
{errors.email && <div className="error">{errors.email}</div>}
<label>Phone Number (10 digits):</label>
<input type="tel" name="phoneNumber" required pattern="\d{10}" />
{errors.phoneNumber && <div className="error">{errors.phoneNumber}</div>}
<button type="submit">Submit</button>
</form>
);
}
export default UncontrolledForm;
There’s one more big benefit to using uncontrolled components.
Forms in React Server Components
React Server Components (RSC) uses server-side frameworks to render some of your components and thereby reduce the amount of JavaScript that web browsers download to run your website. This can drastically improve the performance of your website.
RSC has a big impact on the way we write forms, because, for the first time, if our form inputs do not use state, they can be rendered to HTML on the server and ship 0 JavaScript to the browser. This means that uncontrolled forms can be interactive without JavaScript, making them function earlier rather than waiting for JS to download and execute - in effect, it makes your website feel snappier, there’s no delay to interactivity.
Using Next.js, you can also use Server Actions in your forms, so you don’t need to write an API for your form to interact with - all you need is a handler function. You can find out more info about this topic from the Next.js docs or Lee’s excellent explainer video here.
If you build a mixed form with some controlled inputs in an RSC-rendered form, make sure to encapsulate your controlled components into a separate client-component, similar to the <PhoneInput />
component from earlier, but in its own file. This ensures that only the minimum amount of JavaScript needed will be bundled. It may look like this:
// page.jsx
import { PhoneInput } from "./PhoneInput";
export default function Page() {
async function create(formData: FormData) {
"use server";
// ... use the FormData
}
return (
<form action={create}>
<label>Name:</label>
<input type="text" name="name" />
<label>Email:</label>
<input type="email" name="email" />
<label>Phone Number:</label>
<PhoneInput />
<label>Address:</label>
<input type="text" name="address" />
<button type="submit">Submit</button>
</form>
);
}
// PhoneInput.jsx
"use client";
// Helper function for phone number formatting (just an example)
function formatPhoneNumber(number) {
// Format the phone number as desired. This is just a basic example.
return number.replace(/\D/g, "").slice(0, 10);
}
import { useState } from "react";
export const PhoneInput = () => {
const handlePhoneNumberChange = (event) => {
// Some function to format the phone number
const formattedNumber = formatPhoneNumber(event.target.value);
setPhoneNumber(formattedNumber);
};
const [phoneNumber, setPhoneNumber] = useState("");
return (
<input
type="tel"
name="phoneNumber"
value={phoneNumber}
onChange={handlePhoneNumberChange}
/>
);
};
Form Libraries
There are many great form libraries in the React ecosystem, designed for controlled inputs. Recently I’ve been using React Hook Form for those applications, though I’ve been leaning more heavily on uncontrolled inputs so I don’t need a library to manage form state. (Edit: some popular options are React Hook Form, Formik, and Informed)
Conclusions, Comparisons, and Recommendations
This article was motivated by the confusing, out-dated, and/or misleading articles that show up near the top of the Google search results for “react forms.”
- One of those articles says “the more idiomatic React way would be using controlled forms” - I don’t think either controlled or uncontrolled inputs are more “React idiomatic.” In reality, both types have their place, as discussed above. In fact, many older articles seem to recommend controlled inputs, for similarly vague or misleading reasons. I would argue that using uncontrolled inputs is actually more “web idiomatic” by nature of relying on less JavaScript, which allows uncontrolled inputs to shine in React Server Components.
- Not one of the top articles uses FormData. For uncontrolled forms, at least two recommend multiple useRef calls, making your code inflexible and more boilerplatey.
- Some of the top articles still use class components with no mention of functional components.
Some thoughts in conclusion:
- In my experience, most forms are a mixture of uncontrolled and controlled inputs. The reason we have both of those options is flexibility and we shouldn’t be dogmatic. We can use them both together, as in the React Server Components example above (although server components are not needed for that example).
- Today, I favor uncontrolled inputs wherever I can. I believe this simplifies the code structure and performance.
- Use “new FormData(...)” in your onSubmit functions instead of using refs. Seriously.
- Use composition and encapsulation for controlled inputs to minimize the impact of state updates on other components, and rely on the rendered, composed DOM for submit events.
I hope this article helps someone! Feel free to leave questions in the comments.
Top comments (16)
Or even better, use TypeScript, and use Object.fromEntries:
J
Great addition! Thank you! 🙏
When using React, I've not found a use case yet that React Hook Form didn't satisfy and make my life 1000x simpler
I use class components because instances of such components exist. Each form consists of forms, forms of forms, ... and so on. Each form has a public method
get() {return {dataA: refs.formA.get(), dataB: refs.formB.get(), dataC: ":)"}}
that will return form data that was collected from child forms by calling get().This simple solution allows me to create very large and complex shapes. Unfortunately, this approach is not possible on functional components
You've swayed me away from using controlled fields with state for form data, completely agreed with your rationale for using uncontrolled inputs where possible:
This is my first time hearing of the FormData API, I'll definitely be using that going forward!
Would love to read a follow up where you discuss more about how React-Hook-Form fits into the picture. Great article dude, thanks for taking the time to write it
I nearly didn't read this article thinking I knew enough about forms. 😏
I'm very glad I did. Really great perspective and will definitely make me revisit how I implement forms in future projects.
More broadly, really well written article. 👍
Perfect balance of being concise and providing detail.
Excellent article, it helped me a lot. In my job we created dozens of controlled forms, and it was cumbersome and hard at moments, with a lot of bug potential. I didn't use uncontrolled because I didn't know about new FormData(). The way you manage uncontrolled forms is definitely the way, managing controlled inputs only when needed
Besides FormData we can also make use of the form submit event and access input elements like => event.target.elements.[inputID] or event.target.elements.[inputName]
I really like this approach. The question is, how do you handle more complex forms with nested data in arrays? Something like:
{ name: "James", hobbies: [{name: "football"...}, {name....}]}
This looks good and i definitely will try to use this approach to reduce complexity on my side projects.
And if you want to send form data to your email without touching the server code you can do it with mailik.dev/