React Forms: The Modern Uncontrolled Way
Introduction
Icebreaker: Think about the last plain HTML form you wrote (or saw). Did you need JavaScript at all to make it work? Forms have always "just worked" in the browser. React doesn't change that — but it does change how you think about where the data lives.
Real-world scenario: You're building a simple contact form for a local business website. All you need is: collect name, email, and a message, then display a confirmation. No live validation, no character counters, no fancy UI tricks. Just: "user fills out form → hits submit → see their data."
You could reach for useState and wire up every input with value and onChange. That's the controlled forms input and could be overkill depending on the needs of the form. The browser already knows how to manage form data. Let it do its job?
Learning objectives:
- Understand how uncontrolled inputs work in React (let the browser manage the data)
- Use the modern
actionprop pattern instead ofonSubmit+event.preventDefault() - Read form values on submit using the
FormDataAPI - Build a reusable
Inputcomponent using a 3-step process - Build a complete contact form that displays submitted data
- Recognize when this simple pattern is enough vs. when you'd need more control
Note: There are checkpoint questions throughout this lesson. Be sure to answer them in your written reflection at the end. Work through all exercises and commit your code as you go.
Core Concept Overview
What Are Uncontrolled Inputs?
In React, an uncontrolled input is one where:
- The browser (DOM) is the source of truth for the input's current value
- React renders the
<input>, then mostly stays out of the way - You read the value when you need it — usually on submit
- Little or no
useStateis required
Think of it like this: the browser is already really good at managing text boxes, checkboxes, and select menus. Uncontrolled inputs let the browser do what it does best, and you just read the values when you care about them.
The Modern Pattern: action Prop
Traditionally, React developers used onSubmit with event.preventDefault() to handle form submissions:
// Old pattern (still works, but verbose)
function OldForm() {
function handleSubmit(event) {
event.preventDefault(); // Stop page reload
const formData = new FormData(event.currentTarget);
const name = formData.get("name");
console.log(name);
}
return (
<form onSubmit={handleSubmit}>
<input name="name" type="text" />
<button type="submit">Submit</button>
</form>
);
}
Modern React (as shown in the official React docs) uses the action prop instead:
// Modern pattern (cleaner)
function ModernForm() {
function handleSubmit(formData) {
const name = formData.get("name");
console.log(name);
}
return (
<form action={handleSubmit}>
<input name="name" type="text" />
<button type="submit">Submit</button>
</form>
);
}
What changed?
- No more
event.preventDefault()— React handles that for you - No more
event.currentTarget— theFormDataobject arrives directly as a parameter - Cleaner, more declarative code
Key idea: The
actionprop is the modern way to handle client-side form submissions in React. Use it instead ofonSubmit+event.preventDefault().
Ironically, action was what was used prevalently in forms before JavaScript frameworks took over. Now, React brings it back in a cleaner way! What's old is new again.
Reading Form Data
The FormData API gives you a clean way to read all the values from a form:
function handleSubmit(formData) {
const name = formData.get("name"); // Get one field
const email = formData.get("email"); // Get another
// Or get all fields at once as an object
const data = Object.fromEntries(formData);
console.log(data); // { name: "...", email: "..." }
}
What's Object.fromEntries()? It converts the FormData into a regular JavaScript object. This makes it easy to store in state or send to an API.
Important: Each input needs a name attribute. That's how FormData knows which value belongs to which field.
<input name="email" type="email" /> {/* ✅ Has name */}
<input type="text" /> {/* ❌ No name, won't show up in FormData */}
When Uncontrolled is Enough
Use uncontrolled inputs when:
- You only need the values on submit (no live validation)
- You don't need to format or transform the input while the user types
- You're not coordinating multiple fields that depend on each other
- You want simple, minimal code
This is perfect for:
- Contact forms
- Login forms
- Search boxes
- Any "fill it out, then submit" workflow
You'll need controlled inputs (next lesson) when:
- You need live validation or error messages as the user types
- You want to disable the submit button based on field values
- You need to format input (like auto-formatting phone numbers)
- Multiple components need to read/write the same field
Hands-On Application
Setup
Use the React + Vite template repository provided in Brightspace. It includes:
- ESLint and Prettier pre-configured
- Tailwind CSS ready to use
Your task: Build a contact form app using uncontrolled inputs. All code will go in src/app.jsx.
Part 1: Basic Form Structure
Open src/app.jsx and create your main App component:
import { useState } from "react";
export default function App() {
const [submittedData, setSubmittedData] = useState(null);
function handleSubmit(formData) {
const data = Object.fromEntries(formData);
setSubmittedData(data);
}
return (
<main className="mx-auto max-w-md p-6">
<h1 className="mb-6 text-2xl font-bold">Contact Us</h1>
<form action={handleSubmit} className="space-y-4">
{/* TODO: Add form fields here */}
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Submit
</button>
</form>
{submittedData && (
<section className="mt-6 rounded-lg bg-green-50 p-4">
<h2 className="mb-2 font-semibold text-green-900">Form Submitted!</h2>
<ul className="space-y-1 text-sm">
<li>
<strong>Name:</strong> {submittedData.name}
</li>
<li>
<strong>Email:</strong> {submittedData.email}
</li>
<li>
<strong>Message:</strong> {submittedData.message}
</li>
</ul>
</section>
)}
</main>
);
}
Note: We're using one piece of state (submittedData) to store the form values after submission. The actual form fields remain uncontrolled — the browser manages them until submit.
Test this structure:
- Run
npm run dev - You should see "Contact Us" heading and a Submit button
- Clicking Submit won't do much yet (no fields to submit)
Checkpoint question: In this code, where is formData coming from in the handleSubmit function parameter?
Part 2: Building a Reusable Input Component
Right now, you need to add three form fields: name, email, and message. You could copy-paste the HTML three times. But that's not reusable!
Instead, let's build a reusable Input component using a 3-step process:
Step 1: Start with Static HTML
Below your App component (in the same src/app.jsx file), create an Input component with completely hardcoded HTML:
function Input() {
return (
<label className="block">
<span className="mb-1 block text-sm font-medium">Name</span>
<input
name="name"
type="text"
required
className="w-full rounded border px-3 py-2"
/>
</label>
);
}
Now test it: In your App component's form, add:
<Input />
<Input />
<Input />
You should see three identical "Name" inputs. That's good! The component works, it's just not dynamic yet.
Checkpoint question: Take a screenshot showing your three identical Name inputs.
Step 2: Identify What Needs to Be Dynamic
Look at your hardcoded Input component. What would need to change for each field?
- The label text ("Name", "Email", "Message")
- The
nameattribute (for FormData) - The
typeattribute ("text", "email", "textarea" for message) - Maybe: the
requiredattribute (some fields might be optional)
For a textarea (the message field), you'll need a <textarea> element instead of <input>.
List the props you'll need:
label— the text to displayname— the form field nametype— input type (or "textarea" for textarea)required— boolean, default to true
Stuck? Ask Copilot: "How do I make this Input component accept props for label, name, and type?" or "How do I conditionally render a textarea vs input in React?"
Step 3: Add Props and Make It Dynamic
Update your Input component to accept props:
// Remember default parameters???
function Input({ label, name, type = "text", required = true }) {
const isTextarea = type === "textarea";
return (
<label className="block">
<span className="mb-1 block text-sm font-medium">{label}</span>
{isTextarea ? (
<textarea
name={name}
required={required}
rows="4"
className="w-full rounded border px-3 py-2"
/>
) : (
<input
name={name}
type={type}
required={required}
className="w-full rounded border px-3 py-2"
/>
)}
</label>
);
}
Now update your form in App to use the dynamic component:
<form action={handleSubmit} className="space-y-4">
<Input label="Name" name="name" type="text" />
<Input label="Email" name="email" type="email" />
<Input label="Message" name="message" type="textarea" />
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Submit
</button>
</form>
Test it:
- You should now see three properly labeled fields: Name, Email, and Message
- Fill them out with test data
- Click Submit
- The submitted data should appear below the form
Checkpoint question: What would happen if you forgot to pass the name prop to one of your Input components? Would the form still work?
Part 3: Enhancements
Enhancement A: Clear Button
Add a button that clears both the form fields and the submitted data:
// In your App component, after the submit button:
<button
type="button"
onClick={(event) => {
event.target.form.reset();
setSubmittedData(null);
}}
className="ml-2 rounded bg-gray-400 px-4 py-2 text-white hover:bg-gray-500"
>
Clear
</button>
Note: type="button" prevents it from submitting the form.
Note: type="reset" would only clear the form fields, not the submitted data in state.
Enhancement B: Data-Driven Form Fields
Right now, your form has three <Input /> components written out manually. That works, but it's not very DRY (Don't Repeat Yourself).
The challenge: Refactor your form to be data-driven. Instead of repeating JSX, create an array of field configuration objects and map over them.
Requirements:
- Create a
formFieldsarray (or constant) that describes your three fields - Each object in the array should have the properties your
Inputcomponent needs - Use
.map()to generate the<Input />components - Don't forget the
keyprop when mapping!
Success criteria:
- Your form works exactly the same as before
- You have NO repeated
<Input />JSX in yourAppcomponent - Adding a new field would only require adding one object to your array
Hints:
- Think about what data each field needs: label, name, type, required?
- The spread operator (
{...field}) can pass all props at once - The
keyshould be something unique to each field
Stuck? Try asking Copilot: "How do I map over an array to generate React components?" or "What's the spread operator for passing props?"
Checkpoint question: After implementing this, answer in your reflection: What would you need to do to add a "Phone" field to your form now? Compare this to the manual approach you started with.
Advanced Concepts & Comparisons
Why Not Just Use Plain HTML Forms?
You might wonder: "If we're not using React state for the inputs, why use React at all?"
Good question! React gives you:
- Component reusability: Your
Inputcomponent can be used anywhere - Conditional rendering: Easy to show/hide the success message
- State management: Easy to track submission history or status
- Integration: Easy to send data to an API or parent component later
The inputs themselves are uncontrolled (browser-managed), but the surrounding logic benefits from React.
The action Prop: Client vs Server
The action prop can accept:
- A function (what we're doing): handles the form on the client side
- A URL string: submits the form to a server (like traditional HTML forms)
<form action="/api/contact"> {/* Submits to server */}
In this lesson, we're using client-side handling. Later in the course, you'll see server-side form handling with Next.js Server Actions.
What About useRef?
You might see older React tutorials using useRef to read form values:
const nameRef = useRef();
// Later: nameRef.current.value
This works, but FormData is cleaner:
- ✅
FormData: One parameter gives you all fields - ❌
useRef: Need a separate ref for every field
Rule of thumb: Use FormData with the modern action pattern for uncontrolled forms.
Troubleshooting & Best Practices
Common Issues
Problem: "The page reloads when I submit"
Solution: Make sure you're using action={handleSubmit} (not onSubmit). If you're using onSubmit, you need event.preventDefault().
Problem: "formData.get('name') returns null"
Solution: Check that your input has a name attribute: <input name="name" />
Problem: "The submitted data doesn't show up"
Solution:
- Check that you're calling
setSubmittedData(data)in your submit handler - Verify your conditional rendering:
{submittedData && ...} - Use React DevTools to inspect the state
Problem: "Tailwind classes aren't working"
Solution:
- Make sure the dev server is running (
npm run dev) - Check that your classes are spelled correctly (no typos)
- Verify Tailwind is configured in the template repo
Problem: "My Input component doesn't show up"
Solution:
- Make sure it's defined in the same file (
src/app.jsx) - Check that you're passing all required props (
label,name) - Look for typos in prop names
Best Practices
- Always use
nameattributes on form inputs when usingFormData - Use semantic HTML:
<label>,<input>,<textarea>, etc. - Use the
typeattribute:type="email"gives you browser validation for free - Add
requiredwhen appropriate: Browser will handle basic validation - Keep the handler function simple: Just read data and update state
- Build reusable components: Use the 3-step process (static → identify → props)
- Colocate related components: Keep
InputandAppin the same file for now
Wrap-Up & Assessment
Key Takeaways
- Uncontrolled inputs let the browser manage form data; React reads it on submit
- The modern
actionprop replacesonSubmit+event.preventDefault()for cleaner code - Use
FormDatato read all form values at once - Build reusable components using the 3-step process: static HTML → identify dynamic parts → add props
- This pattern is perfect for simple "fill and submit" forms
- You only need state for displaying submitted data, not for tracking input changes
Assessment
Part 1: Video Walkthrough (2-3 minutes)
Record a screen video demonstrating your contact form:
-
Form demo (60 seconds):
- Show your form in the browser with all three fields visible
- Fill out all three fields with test data
- Submit the form
- Show that the page doesn't reload
- Show the submitted data appearing below the form
- Submit again with different data to show it updates
-
Code explanation (60-90 seconds):
- Open
src/app.jsxin VS Code - Point to the
actionprop and explain what it does - Show your
Inputcomponent and explain the props it accepts - Show your
formFieldsarray and explain what data it contains - Show the
.map()line that generates the inputs and explain how it works - Show the
Object.fromEntries(formData)line and explain what it does
- Open
-
Data-driven reflection (30 seconds):
- Explain: "What's the benefit of using a
formFieldsarray and.map()instead of writing out each<Input />manually?" - Answer: "What would I need to do to add a Phone field to this form?"
- Explain: "What's the benefit of using a
Part 2: Written Reflection
-
From Core Concepts: What's the main benefit of using
actioninstead ofonSubmitfor client-side form handling? -
From Core Concepts: What happens if you forget to add a
nameattribute to an input when usingFormData? -
From Part 1: In the code, where is
formDatacoming from in thehandleSubmitfunction parameter? -
From Part 2 - Step 1: Include your screenshot showing three identical Name inputs. Why is it okay to have duplicate HTML at the "static" stage of building a reusable component?
-
From Part 2 - Step 3: What would happen if you forgot to pass the
nameprop to one of yourInputcomponents? Would the form still work? -
Component design: Look at your final
Inputcomponent. List all the props it accepts. Which props have default values, and why? -
Enhancement reflection (if you did the exercises): Which enhancement(s) did you attempt? What was challenging about it? Include a screenshot if applicable.
-
Comparison: Look at your entire
src/app.jsxfile. Count how many times the worduseStateappears. Count how many times the wordvalueappears (as a prop). What does this tell you about uncontrolled inputs? -
Synthesis: Imagine your boss asks: "Can we add a character counter showing 'Message: 47/500 characters' while the user types?" Could you add this feature with your current uncontrolled form? Why or why not?
Submission checklist:
- [ ] GitHub repository with your complete
src/app.jsxfile - [ ] Working form with three fields using your custom
Inputcomponent - [ ] Enhancement B implemented (data-driven form fields with
.map()) - [ ] Video walkthrough (2-3 minutes)
- [ ] Written reflection answering all 11 questions
- [ ] Screenshot from Part 2, Step 1 (three identical inputs)
- [ ] Code snippet of your
formFieldsarray and.map()implementation (in reflection)
Resources
- React Docs: Form Components
- React Docs: Handling Form Submission
- MDN: FormData
- MDN: HTML Forms
- Tailwind CSS: Forms Plugin (optional styling helper)