Build a signup form that validates on submit and posts to an API. The core is the validation step: gather the fields, run each rule, collect failures into an errors object keyed by field, and only POST when that object is empty. While the request is in flight, disable the button; on success, show a confirmation.
// A self-contained component. No props.
function App(): JSX.Element;
Name / email / password / confirm fields, with per-field errors and a submit.
empty name → errors.name = "Name is required"
"bob@" → errors.email = "Enter a valid email"
password "123" → errors.password = "At least 8 characters"
confirm != password → errors.confirm = "Passwords don't match"
all valid → button disables, request runs, then "Account created!" shows
errors object. One key per field; the form is valid when it has no keys.value + onChange; clear its error as the user fixes it (or on next submit).Validation is a pure function from the field values to an errors object — one key per failing field. The form is valid when that object is empty; only then do you POST. While the request runs you disable the button, and on success you swap the form for a confirmation.
A signup form has to do three things in order: check the inputs, report what's wrong, and (if all good) send them. The clean way to "check" is one function that looks at every field and returns a map of { field: message } for whatever failed. Rendering errors is then just reading that map, and "is the form valid?" is "does the map have any keys?". The network part is an async action gated behind that check, with a loading flag so the user can't fire it twice.
State: the four field values, an errors object, a submitting boolean, and a done boolean. On submit (prevent default), run validate(values) → an errors object. If it has keys, store it and stop. If it's empty, set submitting, await the (simulated) POST, then set done. Each input shows its error from errors[field] and an invalid style.
A first attempt validates inline in the submit handler with scattered booleans:
function onSubmit() {
if (!name) { setNameError('Required'); return; }
if (!email.includes('@')) { setEmailError('Bad'); return; }
// …a separate error state per field, and it stops at the FIRST failure
}
Early-returning on the first failure means the user fixes one error, submits, and discovers the next — death by a thousand submits. And a separate error state per field multiplies the bookkeeping. Collecting all failures into one object shows everything at once and keeps error state in a single place.
import { useState } from 'react';
import './styles.css';
type Values = { name: string; email: string; password: string; confirm: string };
type Errors = { [K in keyof Values]?: string };
function validate(v: Values): Errors {
const errors: Errors = {};
if (!v.name.trim()) errors.name = 'Name is required';
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.email)) errors.email = 'Enter a valid email';
if (v.password.length < 8) errors.password = 'At least 8 characters';
if (v.confirm !== v.password) errors.confirm = "Passwords don't match";
return errors;
}
function createAccount(values: Values): Promise<void> {
// Simulated API call.
return new Promise((resolve) => setTimeout(resolve, 800));
}
export default function App() {
const [values, setValues] = useState<Values>({ name: '', email: '', password: '', confirm: '' });
const [errors, setErrors] = useState<Errors>({});
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
function set(field: keyof Values, value: string) {
setValues((v) => ({ ...v, [field]: value }));
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) return;
setSubmitting(true);
await createAccount(values);
setSubmitting(false);
setDone(true);
}
if (done) {
return (
<main className="container">
<h1>Signup Form</h1>
<p className="success">Account created! Welcome, {values.name}.</p>
</main>
);
}
const fields: [keyof Values, string, string][] = [
['name', 'Name', 'text'],
['email', 'Email', 'text'],
['password', 'Password', 'password'],
['confirm', 'Confirm password', 'password'],
];
return (
<main className="container">
<h1>Signup Form</h1>
<form onSubmit={onSubmit}>
{fields.map(([key, label, type]) => (
<div className="field" key={key}>
<label htmlFor={key}>{label}</label>
<input
id={key}
type={type}
className={errors[key] ? 'invalid' : ''}
value={values[key]}
onChange={(e) => set(key, e.target.value)}
/>
{errors[key] && <p className="error">{errors[key]}</p>}
</div>
))}
<button className="submit" type="submit" disabled={submitting}>
{submitting ? 'Creating…' : 'Create account'}
</button>
</form>
</main>
);
}
validate is pure: same values in, same errors out, with every rule run so all failures surface together. onSubmit prevents the default page reload, validates, and bails if Object.keys(found).length > 0. Only past that gate does it flip submitting, await the simulated POST, and set done. Inputs are controlled via a single set(field, value) updater, and each renders errors[key] plus the invalid border. The done branch replaces the form with a success message.
All fields empty.
onSubmit runs validate: name empty, email invalid, password too short, confirm matches (both empty) — errors = { name, email, password }. setErrors shows all three under their fields; the function returns (no POST).values.validate returns {} (no keys) → past the gate. submitting = true, button shows "Creating…" and is disabled.submitting = false, done = true. The form is replaced by "Account created! Welcome, …".disabled while submitting, a second click can't fire a second request.errors object, then check.preventDefault(). The form does a full page reload. Fix: e.preventDefault() in the handler.disabled={submitting}.email.includes('@'). Too loose. Fix: a real-ish regex (and remember server-side validation is the real gate).errors map keyed by field.errors object (e.g. "email taken").aria-describedby and set aria-invalid.A reducer keeps field edits, validation feedback, request progress, and completion in one predictable state machine.
import { useReducer } from 'react';
import './styles.css';
type Values = { name: string; email: string; password: string; confirm: string };
type Errors = Partial<Record<keyof Values, string>>;
type State = { values: Values; errors: Errors; submitting: boolean; done: boolean };
type Action =
| { type: 'field'; field: keyof Values; value: string }
| { type: 'invalid'; errors: Errors }
| { type: 'submitting' }
| { type: 'done' };
const fields: [keyof Values, string, string][] = [
['name', 'Name', 'text'], ['email', 'Email', 'text'],
['password', 'Password', 'password'], ['confirm', 'Confirm password', 'password'],
];
const initial: State = { values: { name: '', email: '', password: '', confirm: '' }, errors: {}, submitting: false, done: false };
function validate(v: Values): Errors {
const errors: Errors = {};
if (!v.name.trim()) errors.name = 'Name is required';
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.email)) errors.email = 'Enter a valid email';
if (v.password.length < 8) errors.password = 'At least 8 characters';
if (v.confirm !== v.password) errors.confirm = "Passwords don't match";
return errors;
}
function reducer(state: State, action: Action): State {
if (action.type === 'field') return { ...state, values: { ...state.values, [action.field]: action.value } };
if (action.type === 'invalid') return { ...state, errors: action.errors };
if (action.type === 'submitting') return { ...state, errors: {}, submitting: true };
return { ...state, submitting: false, done: true };
}
const createAccount = () => new Promise<void>((resolve) => setTimeout(resolve, 800));
export default function App() {
const [state, dispatch] = useReducer(reducer, initial);
async function submit(event: React.FormEvent) {
event.preventDefault();
const errors = validate(state.values);
if (Object.keys(errors).length) { dispatch({ type: 'invalid', errors }); return; }
dispatch({ type: 'submitting' });
await createAccount();
dispatch({ type: 'done' });
}
return <main className="container"><h1>Signup Form</h1>{state.done
? <p className="success">Account created! Welcome, {state.values.name}.</p>
: <form onSubmit={submit}>{fields.map(([key, label, type]) => <div className="field" key={key}><label htmlFor={key}>{label}</label><input id={key} type={type} className={state.errors[key] ? 'invalid' : ''} value={state.values[key]} onChange={(event) => dispatch({ type: 'field', field: key, value: event.target.value })}/>{state.errors[key] && <p className="error">{state.errors[key]}</p>}</div>)}<button className="submit" type="submit" disabled={state.submitting}>{state.submitting ? 'Creating…' : 'Create account'}</button></form>}
</main>;
}A custom hook owns the workflow while the component remains a direct projection of the same form state.
import { useState } from 'react';
import './styles.css';
type Values = { name: string; email: string; password: string; confirm: string };
type Errors = Partial<Record<keyof Values, string>>;
const fields: [keyof Values, string, string][] = [['name', 'Name', 'text'], ['email', 'Email', 'text'], ['password', 'Password', 'password'], ['confirm', 'Confirm password', 'password']];
function getErrors(v: Values): Errors { const e: Errors = {}; if (!v.name.trim()) e.name = 'Name is required'; if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.email)) e.email = 'Enter a valid email'; if (v.password.length < 8) e.password = 'At least 8 characters'; if (v.confirm !== v.password) e.confirm = "Passwords don't match"; return e; }
const createAccount = () => new Promise<void>((resolve) => setTimeout(resolve, 800));
function useSignup() {
const [values, setValues] = useState<Values>({ name: '', email: '', password: '', confirm: '' });
const [errors, setErrors] = useState<Errors>({}); const [submitting, setSubmitting] = useState(false); const [done, setDone] = useState(false);
const set = (field: keyof Values, value: string) => setValues((current) => ({ ...current, [field]: value }));
const submit = async (event: React.FormEvent) => { event.preventDefault(); const next = getErrors(values); setErrors(next); if (Object.keys(next).length) return; setSubmitting(true); await createAccount(); setSubmitting(false); setDone(true); };
return { values, errors, submitting, done, set, submit };
}
export default function App() { const form = useSignup(); return <main className="container"><h1>Signup Form</h1>{form.done ? <p className="success">Account created! Welcome, {form.values.name}.</p> : <form onSubmit={form.submit}>{fields.map(([key, label, type]) => <div className="field" key={key}><label htmlFor={key}>{label}</label><input id={key} type={type} className={form.errors[key] ? 'invalid' : ''} value={form.values[key]} onChange={(event) => form.set(key, event.target.value)}/>{form.errors[key] && <p className="error">{form.errors[key]}</p>}</div>)}<button className="submit" type="submit" disabled={form.submitting}>{form.submitting ? 'Creating…' : 'Create account'}</button></form>}</main>; }Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a signup form that validates on submit and posts to an API. The core is the validation step: gather the fields, run each rule, collect failures into an errors object keyed by field, and only POST when that object is empty. While the request is in flight, disable the button; on success, show a confirmation.
// A self-contained component. No props.
function App(): JSX.Element;
Name / email / password / confirm fields, with per-field errors and a submit.
empty name → errors.name = "Name is required"
"bob@" → errors.email = "Enter a valid email"
password "123" → errors.password = "At least 8 characters"
confirm != password → errors.confirm = "Passwords don't match"
all valid → button disables, request runs, then "Account created!" shows
errors object. One key per field; the form is valid when it has no keys.value + onChange; clear its error as the user fixes it (or on next submit).Validation is a pure function from the field values to an errors object — one key per failing field. The form is valid when that object is empty; only then do you POST. While the request runs you disable the button, and on success you swap the form for a confirmation.
A signup form has to do three things in order: check the inputs, report what's wrong, and (if all good) send them. The clean way to "check" is one function that looks at every field and returns a map of { field: message } for whatever failed. Rendering errors is then just reading that map, and "is the form valid?" is "does the map have any keys?". The network part is an async action gated behind that check, with a loading flag so the user can't fire it twice.
State: the four field values, an errors object, a submitting boolean, and a done boolean. On submit (prevent default), run validate(values) → an errors object. If it has keys, store it and stop. If it's empty, set submitting, await the (simulated) POST, then set done. Each input shows its error from errors[field] and an invalid style.
A first attempt validates inline in the submit handler with scattered booleans:
function onSubmit() {
if (!name) { setNameError('Required'); return; }
if (!email.includes('@')) { setEmailError('Bad'); return; }
// …a separate error state per field, and it stops at the FIRST failure
}
Early-returning on the first failure means the user fixes one error, submits, and discovers the next — death by a thousand submits. And a separate error state per field multiplies the bookkeeping. Collecting all failures into one object shows everything at once and keeps error state in a single place.
import { useState } from 'react';
import './styles.css';
type Values = { name: string; email: string; password: string; confirm: string };
type Errors = { [K in keyof Values]?: string };
function validate(v: Values): Errors {
const errors: Errors = {};
if (!v.name.trim()) errors.name = 'Name is required';
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.email)) errors.email = 'Enter a valid email';
if (v.password.length < 8) errors.password = 'At least 8 characters';
if (v.confirm !== v.password) errors.confirm = "Passwords don't match";
return errors;
}
function createAccount(values: Values): Promise<void> {
// Simulated API call.
return new Promise((resolve) => setTimeout(resolve, 800));
}
export default function App() {
const [values, setValues] = useState<Values>({ name: '', email: '', password: '', confirm: '' });
const [errors, setErrors] = useState<Errors>({});
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
function set(field: keyof Values, value: string) {
setValues((v) => ({ ...v, [field]: value }));
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) return;
setSubmitting(true);
await createAccount(values);
setSubmitting(false);
setDone(true);
}
if (done) {
return (
<main className="container">
<h1>Signup Form</h1>
<p className="success">Account created! Welcome, {values.name}.</p>
</main>
);
}
const fields: [keyof Values, string, string][] = [
['name', 'Name', 'text'],
['email', 'Email', 'text'],
['password', 'Password', 'password'],
['confirm', 'Confirm password', 'password'],
];
return (
<main className="container">
<h1>Signup Form</h1>
<form onSubmit={onSubmit}>
{fields.map(([key, label, type]) => (
<div className="field" key={key}>
<label htmlFor={key}>{label}</label>
<input
id={key}
type={type}
className={errors[key] ? 'invalid' : ''}
value={values[key]}
onChange={(e) => set(key, e.target.value)}
/>
{errors[key] && <p className="error">{errors[key]}</p>}
</div>
))}
<button className="submit" type="submit" disabled={submitting}>
{submitting ? 'Creating…' : 'Create account'}
</button>
</form>
</main>
);
}
validate is pure: same values in, same errors out, with every rule run so all failures surface together. onSubmit prevents the default page reload, validates, and bails if Object.keys(found).length > 0. Only past that gate does it flip submitting, await the simulated POST, and set done. Inputs are controlled via a single set(field, value) updater, and each renders errors[key] plus the invalid border. The done branch replaces the form with a success message.
All fields empty.
onSubmit runs validate: name empty, email invalid, password too short, confirm matches (both empty) — errors = { name, email, password }. setErrors shows all three under their fields; the function returns (no POST).values.validate returns {} (no keys) → past the gate. submitting = true, button shows "Creating…" and is disabled.submitting = false, done = true. The form is replaced by "Account created! Welcome, …".disabled while submitting, a second click can't fire a second request.errors object, then check.preventDefault(). The form does a full page reload. Fix: e.preventDefault() in the handler.disabled={submitting}.email.includes('@'). Too loose. Fix: a real-ish regex (and remember server-side validation is the real gate).errors map keyed by field.errors object (e.g. "email taken").aria-describedby and set aria-invalid.A reducer keeps field edits, validation feedback, request progress, and completion in one predictable state machine.
import { useReducer } from 'react';
import './styles.css';
type Values = { name: string; email: string; password: string; confirm: string };
type Errors = Partial<Record<keyof Values, string>>;
type State = { values: Values; errors: Errors; submitting: boolean; done: boolean };
type Action =
| { type: 'field'; field: keyof Values; value: string }
| { type: 'invalid'; errors: Errors }
| { type: 'submitting' }
| { type: 'done' };
const fields: [keyof Values, string, string][] = [
['name', 'Name', 'text'], ['email', 'Email', 'text'],
['password', 'Password', 'password'], ['confirm', 'Confirm password', 'password'],
];
const initial: State = { values: { name: '', email: '', password: '', confirm: '' }, errors: {}, submitting: false, done: false };
function validate(v: Values): Errors {
const errors: Errors = {};
if (!v.name.trim()) errors.name = 'Name is required';
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.email)) errors.email = 'Enter a valid email';
if (v.password.length < 8) errors.password = 'At least 8 characters';
if (v.confirm !== v.password) errors.confirm = "Passwords don't match";
return errors;
}
function reducer(state: State, action: Action): State {
if (action.type === 'field') return { ...state, values: { ...state.values, [action.field]: action.value } };
if (action.type === 'invalid') return { ...state, errors: action.errors };
if (action.type === 'submitting') return { ...state, errors: {}, submitting: true };
return { ...state, submitting: false, done: true };
}
const createAccount = () => new Promise<void>((resolve) => setTimeout(resolve, 800));
export default function App() {
const [state, dispatch] = useReducer(reducer, initial);
async function submit(event: React.FormEvent) {
event.preventDefault();
const errors = validate(state.values);
if (Object.keys(errors).length) { dispatch({ type: 'invalid', errors }); return; }
dispatch({ type: 'submitting' });
await createAccount();
dispatch({ type: 'done' });
}
return <main className="container"><h1>Signup Form</h1>{state.done
? <p className="success">Account created! Welcome, {state.values.name}.</p>
: <form onSubmit={submit}>{fields.map(([key, label, type]) => <div className="field" key={key}><label htmlFor={key}>{label}</label><input id={key} type={type} className={state.errors[key] ? 'invalid' : ''} value={state.values[key]} onChange={(event) => dispatch({ type: 'field', field: key, value: event.target.value })}/>{state.errors[key] && <p className="error">{state.errors[key]}</p>}</div>)}<button className="submit" type="submit" disabled={state.submitting}>{state.submitting ? 'Creating…' : 'Create account'}</button></form>}
</main>;
}A custom hook owns the workflow while the component remains a direct projection of the same form state.
import { useState } from 'react';
import './styles.css';
type Values = { name: string; email: string; password: string; confirm: string };
type Errors = Partial<Record<keyof Values, string>>;
const fields: [keyof Values, string, string][] = [['name', 'Name', 'text'], ['email', 'Email', 'text'], ['password', 'Password', 'password'], ['confirm', 'Confirm password', 'password']];
function getErrors(v: Values): Errors { const e: Errors = {}; if (!v.name.trim()) e.name = 'Name is required'; if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.email)) e.email = 'Enter a valid email'; if (v.password.length < 8) e.password = 'At least 8 characters'; if (v.confirm !== v.password) e.confirm = "Passwords don't match"; return e; }
const createAccount = () => new Promise<void>((resolve) => setTimeout(resolve, 800));
function useSignup() {
const [values, setValues] = useState<Values>({ name: '', email: '', password: '', confirm: '' });
const [errors, setErrors] = useState<Errors>({}); const [submitting, setSubmitting] = useState(false); const [done, setDone] = useState(false);
const set = (field: keyof Values, value: string) => setValues((current) => ({ ...current, [field]: value }));
const submit = async (event: React.FormEvent) => { event.preventDefault(); const next = getErrors(values); setErrors(next); if (Object.keys(next).length) return; setSubmitting(true); await createAccount(); setSubmitting(false); setDone(true); };
return { values, errors, submitting, done, set, submit };
}
export default function App() { const form = useSignup(); return <main className="container"><h1>Signup Form</h1>{form.done ? <p className="success">Account created! Welcome, {form.values.name}.</p> : <form onSubmit={form.submit}>{fields.map(([key, label, type]) => <div className="field" key={key}><label htmlFor={key}>{label}</label><input id={key} type={type} className={form.errors[key] ? 'invalid' : ''} value={form.values[key]} onChange={(event) => form.set(key, event.target.value)}/>{form.errors[key] && <p className="error">{form.errors[key]}</p>}</div>)}<button className="submit" type="submit" disabled={form.submitting}>{form.submitting ? 'Creating…' : 'Create account'}</button></form>}</main>; }Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.