Forms and validation
Build a Brew & Bean pickup-order form with controlled inputs, one shared change handler, validation on submit and clear error messages. Learn why the server must still check everything.
In this tutorial you will build a pickup order form for Brew & Bean: customer name, phone number, item, quantity and pickup time. You will keep every field in React state, check the values, show helpful errors, and finally send the order to the API.
Prerequisites: a Vite + React project and the lessons *State and events* and *Lists, keys and conditional rendering*. Plain JavaScript (JSX).
Controlled inputs
In plain HTML, an input keeps its own value inside the browser. In React you usually do the opposite: React state is the single source of truth and the input just displays it. This is called a controlled input.
The pattern has two halves: value={name} tells the input what to show, and onChange runs every time the user types, so you can update the state. Like a bank passbook: the bank (state) holds the real balance, the passbook (input) only shows it.
import { useState } from 'react';
function OrderForm() {
const [name, setName] = useState('');
return (
<form>
<label htmlFor="name">Your name</label>
<input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<p>Hello, {name || 'guest'}!</p>
</form>
);
}
export default OrderForm;event.target.value is the text currently in the input. The greeting updates on every keystroke.
Value without onChange
If you set value but no onChange, the input becomes read-only and React warns you in the console. If you set value={undefined} first and a string later, React warns about switching from uncontrolled to controlled. Always start state with a string, such as ''.
Always connect a label to its input with htmlFor (in JSX the HTML attribute for is written htmlFor) and a matching id. Clicking the label then focuses the input, and screen readers announce the label.
One state object for the whole form
Five fields do not need five useState calls. Keep one object and one change handler. The trick is the name attribute on each input: it must match the key in the state object, and [event.target.name] uses it as a computed key.
import { useState } from 'react';
const ITEMS = [
{ id: 'espresso', name: 'Espresso', price: 120 },
{ id: 'cappuccino', name: 'Cappuccino', price: 180 },
{ id: 'cold-brew', name: 'Cold Brew', price: 220 },
{ id: 'filter-coffee', name: 'Filter Coffee', price: 80 },
{ id: 'masala-chai', name: 'Masala Chai', price: 90 },
{ id: 'croissant', name: 'Butter Croissant', price: 150 },
{ id: 'banana-bread', name: 'Banana Bread', price: 130 },
{ id: 'veg-sandwich', name: 'Veg Grilled Sandwich', price: 160 },
];
const EMPTY = {
customer_name: '',
phone: '',
item_id: '',
qty: 1,
pickup_time: '',
};
function OrderForm() {
const [values, setValues] = useState(EMPTY);
function handleChange(event) {
const { name, value } = event.target;
setValues((previous) => ({ ...previous, [name]: value }));
}
return (
<form>
<label htmlFor="customer_name">Your name</label>
<input
id="customer_name"
name="customer_name"
value={values.customer_name}
onChange={handleChange}
/>
<label htmlFor="phone">Mobile number</label>
<input
id="phone"
name="phone"
type="tel"
inputMode="numeric"
value={values.phone}
onChange={handleChange}
/>
<label htmlFor="item_id">Item</label>
<select
id="item_id"
name="item_id"
value={values.item_id}
onChange={handleChange}
>
<option value="">Choose an item</option>
{ITEMS.map((item) => (
<option key={item.id} value={item.id}>
{item.name} - ₹{item.price}
</option>
))}
</select>
<label htmlFor="qty">Quantity</label>
<input
id="qty"
name="qty"
type="number"
min="1"
max="10"
value={values.qty}
onChange={handleChange}
/>
<label htmlFor="pickup_time">Pickup time</label>
<input
id="pickup_time"
name="pickup_time"
type="time"
min="08:00"
max="22:00"
value={values.pickup_time}
onChange={handleChange}
/>
<button type="submit">Place order</button>
</form>
);
}
export default OrderForm;The setter uses the function form (previous) => ... so it always builds on the latest state. The spread copies every existing field and the computed key replaces just the one that changed.
Two details to remember. First, event.target.value is always a string, even for type="number". We convert qty to a number when we validate and submit. Second, you must create a new object in setValues. Writing values.phone = '98...' changes the old object in place and React will not re-render.
Handling submit
A form's submit event makes the browser reload the page by default. Call event.preventDefault() first. Putting the logic in onSubmit on the form (not onClick on the button) means pressing Enter in a field also works.
Write the validation
- 1Create a function
validate(values)outside the component. It receives the values and returns an object of error messages, one per bad field. An empty object means the form is valid. - 2Check each field with simple rules: name not empty after
trim(), phone is exactly 10 digits starting with 6 to 9, an item is chosen, quantity is a whole number from 1 to 10, pickup time is between 08:00 and 22:00. - 3Add
const [errors, setErrors] = useState({});to the component. - 4In
handleSubmit, callevent.preventDefault(), runvalidate(values), store the result withsetErrors, and stop if there are any errors. - 5Under each input, show the message when it exists:
{errors.phone && <p role="alert">{errors.phone}</p>}.
export function validate(values) {
const errors = {};
if (!values.customer_name.trim()) {
errors.customer_name = 'Please enter your name.';
}
if (!/^[6-9]\d{9}$/.test(values.phone)) {
errors.phone = 'Enter a 10-digit mobile number starting with 6, 7, 8 or 9.';
}
if (!values.item_id) {
errors.item_id = 'Please choose an item.';
}
const qty = Number(values.qty);
if (!Number.isInteger(qty) || qty < 1 || qty > 10) {
errors.qty = 'Quantity must be a whole number from 1 to 10.';
}
if (!values.pickup_time) {
errors.pickup_time = 'Please choose a pickup time.';
} else if (values.pickup_time < '08:00' || values.pickup_time > '22:00') {
errors.pickup_time = 'We are open from 8 am to 10 pm.';
}
return errors;
}Time values from <input type="time"> look like "09:30", so comparing them as strings works because the format is fixed-width. The regular expression ^[6-9]\d{9}$ means: one digit 6 to 9, then nine more digits, nothing else.
import { validate } from './validate';
// inside OrderForm(), below the values state:
const [errors, setErrors] = useState({});
function handleSubmit(event) {
event.preventDefault();
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) {
return; // stop: the form has problems
}
console.log('Valid order', { ...values, qty: Number(values.qty) });
}
// and on the form tag:
// <form onSubmit={handleSubmit} noValidate>
// under the phone input, for example:
// {errors.phone && <p role="alert" style={{ color: 'crimson' }}>{errors.phone}</p>}noValidate switches off the browser's own popups so your messages are the ones users see. Repeat the error line under each field, using its key (customer_name, item_id, qty, pickup_time).
Click Place order with everything empty: a red message appears under name, phone, item and pickup time. Fill them correctly and click again: the messages disappear and the browser console prints "Valid order" with your values and qty as a number.When to validate
Validating on submit is the least annoying. Validating on every keystroke shows 'invalid phone' while the user is still typing. A good middle path: validate a field when it loses focus (onBlur), and re-check all fields on submit. After a failed submit, clear a field's error as soon as the user edits it.
Sending the order
The Brew & Bean API accepts POST /orders with the customer's name, phone, items and pickup time. The server calculates the total itself. Never send a price from the browser and trust it, because anyone can edit what the browser sends.
The code below also disables the button while the request is running to prevent double orders, and shows a success or error message. It assumes the API runs locally at http://localhost:8000 (see the FastAPI lessons in Backend Development); if you do not have it yet, the catch branch will show the error message, which is also a good thing to test.
const [status, setStatus] = useState('idle'); // idle | sending | done | failed
async function handleSubmit(event) {
event.preventDefault();
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) return;
setStatus('sending');
try {
const response = await fetch('http://localhost:8000/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_name: values.customer_name.trim(),
phone: values.phone,
items: [{ item_id: values.item_id, qty: Number(values.qty) }],
pickup_time: values.pickup_time,
}),
});
if (!response.ok) {
throw new Error('Server said ' + response.status);
}
setStatus('done');
setValues(EMPTY);
} catch (error) {
setStatus('failed');
}
}
// in the JSX:
// <button type="submit" disabled={status === 'sending'}>
// {status === 'sending' ? 'Placing order...' : 'Place order'}
// </button>
// {status === 'done' && <p>Order placed! See you soon.</p>}
// {status === 'failed' && <p role="alert">Could not place the order. Please try again.</p>}fetch only rejects on network failure, so we also check response.ok for 4xx and 5xx replies. If the browser blocks the call with a CORS error, the API must allow your Vite origin (usually http://localhost:5173).
Client validation is for users, not for security
Anyone can skip your form and call the API directly. The server must validate everything again (FastAPI and Pydantic do this for you) and calculate prices itself. Treat browser validation as a convenience that saves a round trip, never as protection.
Controlled vs uncontrolled, and form libraries
Controlled inputs re-render the component on every keystroke. For a small form that is fine. For forms with dozens of fields, libraries such as React Hook Form keep values in the DOM (uncontrolled) and read them when needed, which reduces re-renders. Schema libraries such as Zod describe the rules once and are often paired with them. Learn the manual way first so you understand what the libraries automate.
A simple uncontrolled option exists too: read the whole form on submit with new FormData(event.target). It is handy for tiny forms like a search box.
I built a React order form for a coffee shop with these fields: [list fields]. Here is my validate function:
---
[paste validate.js]
---
List inputs that would wrongly pass or wrongly fail (for example spaces, +91 prefixes, quantity '2.5', empty strings). Give 8 test values with the expected result for each. Do not rewrite the function.What to expect: A good answer finds real gaps (for example a phone typed as +91 98..., or spaces inside the number) and gives a table of test inputs you can try by hand.
Try it: Improve the form
1. Show a live order total under the form using the price of the selected item times qty. This is only a preview; the server computes the real total.
2. Validate a field on onBlur and clear its error when the user types in it again.
3. Allow the customer to add up to three different items to one order (an array of { item_id, qty } rows) with an Add item button.
4. Disable the Place order button until the form is valid.
Key takeaways
- A controlled input shows
valuefrom state and updates it inonChange; always start state with a string. - One state object plus one handler using the input's
namehandles many fields. - Call
event.preventDefault()inonSubmit, validate, store errors in state and show them next to each field. event.target.valueis always a string; convert numbers yourself.- Use
labelwithhtmlFor,role="alert"for errors, and disable the button while sending. - Browser validation is for users; the server must validate and compute prices again.
Check yourself
Finishing the quiz marks the lesson as done.
1. What makes an input "controlled" in React?
2. What does setValues((previous) => ({ ...previous, [name]: value })) do?
3. A user types 2 into an input with type="number". What is typeof event.target.value?
4. Why must the server validate an order even if the React form already did?
5. Your form reloads the page when submitted. What is missing?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.