07 · Forms & Controlled Inputs¶
Forms are where React's "UI is a function of state" idea is tested hardest. The same input can be managed two ways: React owns the value (controlled) or the DOM owns it (uncontrolled). Knowing both, and when to use which, saves a lot of pain.
Controlled inputs¶
import { useState } from 'react'
function NameField() {
const [name, setName] = useState('')
return (
<label>
Name
<input value={name} onChange={e => setName(e.target.value)} />
<small>{name.length}/40</small>
</label>
)
}
The input's value comes from state, and every keystroke updates state. React is the
single source of truth, so you can transform, validate or react to the value
instantly — character counters, formatting, disabling a button until valid.
If you give an input value without onChange, it becomes read-only and React warns.
Use defaultValue if you meant an uncontrolled input with a starting value.
One state object for many fields¶
function AddressForm() {
const [form, setForm] = useState({ line1: '', city: '', pin: '' })
function handleChange(e) {
const { name, value } = e.target
setForm(prev => ({ ...prev, [name]: value }))
}
return (
<>
<input name="line1" value={form.line1} onChange={handleChange} />
<input name="city" value={form.city} onChange={handleChange} />
<input name="pin" value={form.pin} onChange={handleChange} inputMode="numeric" />
</>
)
}
[name]: value is a computed property key, so one handler serves every field whose
name attribute matches a key in the state object.
Other input types¶
| Element | Controlled via | Read in handler |
|---|---|---|
| text, email, number, textarea | value |
e.target.value (a string) |
| checkbox | checked |
e.target.checked |
| radio group | checked={choice === 'x'} |
e.target.value |
| select | value on <select> |
e.target.value |
| multi-select | value={array} + multiple |
[...e.target.selectedOptions].map(o => o.value) |
<textarea value={bio} onChange={e => setBio(e.target.value)} rows={4} />
<select value={plan} onChange={e => setPlan(e.target.value)}>
<option value="basic">Basic</option>
<option value="pro">Pro</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={e => setAgree(e.target.checked)} />
I agree to the terms
</label>
{['card', 'upi', 'cod'].map(m => (
<label key={m}>
<input type="radio" name="pay" value={m} checked={method === m}
onChange={e => setMethod(e.target.value)} />
{m.toUpperCase()}
</label>
))}
In JSX, <textarea> uses a value prop rather than children, and <select> takes
value instead of selected on an option.
Uncontrolled inputs and useRef¶
Sometimes you don't need the value on every keystroke — only at submit. Then let the DOM hold it:
function QuickNote({ onSave }) {
function handleSubmit(e) {
e.preventDefault()
const data = new FormData(e.currentTarget)
onSave(data.get('note'))
e.currentTarget.reset()
}
return (
<form onSubmit={handleSubmit}>
<input name="note" defaultValue="" />
<button>Save</button>
</form>
)
}
To reach a DOM node directly — to focus it, measure it, or read a file input — use a ref:
import { useRef } from 'react'
function SearchBar() {
const inputRef = useRef(null)
return (
<>
<input ref={inputRef} type="search" />
<button type="button" onClick={() => inputRef.current.focus()}>Focus search</button>
</>
)
}
useRef(initial) returns an object { current: initial } that survives re-renders.
After React creates the <input>, it sets inputRef.current to that DOM node.
Changing ref.current does not trigger a re-render, which is why refs are the
escape hatch for things React doesn't need to display.
File inputs are always uncontrolled because their value can't be set by code for security reasons.
Worked example: a registration form with validation¶
import { useState } from 'react'
const empty = { email: '', password: '', confirm: '', role: 'student', terms: false }
function validate(v) {
const errors = {}
if (!/^\S+@\S+\.\S+$/.test(v.email)) errors.email = 'Enter a valid email address.'
if (v.password.length < 8) errors.password = 'Use at least 8 characters.'
if (v.confirm !== v.password) errors.confirm = 'Passwords do not match.'
if (!v.terms) errors.terms = 'You must accept the terms.'
return errors
}
export default function Register() {
const [values, setValues] = useState(empty)
const [touched, setTouched] = useState({})
const [submitted, setSubmitted] = useState(false)
const errors = validate(values) // derived, not stored
const isValid = Object.keys(errors).length === 0
const show = field => (touched[field] || submitted) && errors[field]
function handleChange(e) {
const { name, type, value, checked } = e.target
setValues(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value }))
}
function handleBlur(e) {
setTouched(prev => ({ ...prev, [e.target.name]: true }))
}
function handleSubmit(e) {
e.preventDefault()
setSubmitted(true)
if (!isValid) return
alert(`Registered ${values.email} as ${values.role}`)
setValues(empty)
setTouched({})
setSubmitted(false)
}
return (
<form onSubmit={handleSubmit} noValidate>
<label>
Email
<input name="email" type="email" value={values.email}
onChange={handleChange} onBlur={handleBlur}
aria-invalid={Boolean(show('email'))} aria-describedby="email-err" />
</label>
{show('email') && <p id="email-err" className="error">{errors.email}</p>}
<label>
Password
<input name="password" type="password" value={values.password}
onChange={handleChange} onBlur={handleBlur} />
</label>
{show('password') && <p className="error">{errors.password}</p>}
<label>
Confirm password
<input name="confirm" type="password" value={values.confirm}
onChange={handleChange} onBlur={handleBlur} />
</label>
{show('confirm') && <p className="error">{errors.confirm}</p>}
<label>
Role
<select name="role" value={values.role} onChange={handleChange}>
<option value="student">Student</option>
<option value="mentor">Mentor</option>
</select>
</label>
<label>
<input name="terms" type="checkbox" checked={values.terms}
onChange={handleChange} onBlur={handleBlur} />
I accept the terms
</label>
{show('terms') && <p className="error">{errors.terms}</p>}
<button type="submit">Create account</button>
</form>
)
}
Design choices worth copying:
- Errors are derived from values on every render rather than stored, so they can never be out of date.
- Errors appear only after a field is touched (blurred) or the form is submitted — nobody wants "invalid email" after typing one letter.
noValidateturns off the browser's built-in bubbles so the custom messages are the only ones shown;aria-invalidandaria-describedbykeep them accessible.
This example stops at an alert because it has no backend. Never treat client-side
validation as security: the server must validate again.
How It Actually Works¶
A controlled input is a small loop: the DOM fires an input event → React's delegated
listener calls your onChange → you set state → React re-renders → React sets the DOM
node's value property to your state.
But the browser has already changed the input's value before your handler runs. So what stops the user typing anything when you refuse to update state (say, rejecting non-digits)? After the event handler finishes, React checks every controlled input involved in the event and restores its DOM value to match the current prop value if they differ. That "controlled value restoration" is why a controlled input genuinely cannot hold a value that isn't in state.
One subtle consequence: state updates from a text input are processed synchronously
at high priority. If you updated input state inside a setTimeout or after an await,
React would restore the old value first and the caret would jump to the end. Keep the
setState for the input's value directly in the onChange handler.
Switching an input between controlled and uncontrolled (value={undefined} on one render
and a string on the next) confuses this mechanism, which is why React warns "A
component is changing an uncontrolled input to be controlled". Always initialise
controlled values to '', not undefined.
Refs work because React fills in ref.current during the commit phase, after the
DOM node exists and before effects run. During rendering, ref.current for a DOM ref
is still null on the first render, so read refs in event handlers or effects, not in
the render body.
Common mistakes¶
valuewithoutonChange→ a frozen, read-only input.- Initial state
undefined→ the controlled/uncontrolled warning. Use''orfalse. - Storing
isValid/errorsin state alongside values, then forgetting to update them. Derive them. - Number inputs:
e.target.valueis still a string (possibly''). Convert withNumber()when computing, but keep the raw string in state so the user can clear the field. - Missing
preventDefault()inonSubmit→ the page reloads and your state vanishes.
Exercise¶
Build a LoanCalculator form:
- Controlled fields: principal (₹), annual interest rate (%), tenure in years
(a
<select>of 1–30) and a "Show amortization" checkbox. - Compute the EMI on every render using
EMI = P × r × (1+r)^n / ((1+r)^n − 1)whereris the monthly rate andnthe number of months. Guard against a 0% rate. - Show field errors (principal must be > 0, rate between 0 and 30) only after blur.
- Add a "Focus principal" button that uses
useRefto focus the first field. - When the checkbox is on, render a table of the first 12 months: interest, principal and remaining balance.