Cheat SheetsReactForms

Forms — Cheat Sheet

React · 6 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Forms
React6 topicsQuick revision reference
1

Controlled and Uncontrolled Inputs

A controlled input renders from state and updates it on every keystroke. An uncontrolled input keeps its own value in the DOM. Mixing them produces React's most familiar warning.

  • A controlled input renders from state and updates it on every keystroke, which enables live validation and formatting
  • An uncontrolled input keeps its value in the DOM and is read via FormData on submit — no renders while typing
  • defaultValue seeds an uncontrolled input; value makes it controlled
  • Starting state as undefined or null makes an input switch from uncontrolled to controlled, producing React's classic warning
  • A file input cannot be controlled, and large forms often prefer uncontrolled for performance
value + onChange, and one handler for many fields
const [email, setEmail] = useState('')

<input
  value={email}
  onChange={(e) => setEmail(e.target.value)}
/>

// Because state owns it, you can act on every keystroke
<input
  value={pin}
  onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
// digits only, max six — the user cannot type anything else

// The other input types
<input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)} />
<select value={level} onChange={e => setLevel(e.target.value)}>
<textarea value={notes} onChange={e => setNotes(e.target.value)} />

// Several fields in one object
const [values, setValues] = useState({ name: '', email: '' })
const onChange = (e) =>
  setValues(v => ({ ...v, [e.target.name]: e.target.value }))
<input name="email" value={values.email} onChange={onChange} />
2

Validation — Rules, Timing and Messages

Define the rules once as a schema, validate on blur rather than on every keystroke, and never treat client validation as security.

  • Define validation rules once as a schema, which in TypeScript also produces the values type
  • Validate on blur, then on change once a field has already errored, and everything on submit
  • Move focus to the first invalid field on submit, and link messages with aria-describedby
  • Client validation is convenience only — the server rule is the real one and can always reject
  • Map server 422 errors back onto individual fields and never clear the user's input on failure
Zod: rules, messages and types in one place
import { z } from 'zod'

const SignupSchema = z.object({
  name: z.string().min(2, 'Please enter your name'),
  email: z.string().email('That does not look like an email'),
  password: z.string()
    .min(8, 'At least 8 characters')
    .regex(/[0-9]/, 'Include a number'),
  confirm: z.string(),
  age: z.coerce.number().int().min(13, 'You must be 13 or older'),
}).refine(v => v.password === v.confirm, {
  message: 'Passwords do not match',
  path: ['confirm'],                 // attaches the error to the right field
})

type SignupValues = z.infer<typeof SignupSchema>    // the type, for free

// Validating gives you either the parsed values or field errors
const result = SignupSchema.safeParse(values)
if (!result.success) setErrors(result.error.flatten().fieldErrors)

// The same schema can run on a Node backend, which is the strongest
// version of "define the rules once".
3

React Hook Form

The library nearly every React job uses for forms. It keeps inputs uncontrolled for speed, wires validation to a schema, and removes the boilerplate you would otherwise write for every field.

  • register wires an input straight to the DOM, so typing does not re-render the form
  • handleSubmit runs validation first and only calls your handler with valid values
  • formState gives errors, isSubmitting, isDirty and isValid — the flags you would otherwise track by hand
  • setError puts server failures into the same display as client validation errors
  • Controller adapts components that expose their own value/onChange instead of a DOM ref
register, handleSubmit, formState
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isDirty, isValid },
    reset,
    setError,
  } = useForm({
    resolver: zodResolver(SignupSchema),     // the schema from the last concept
    defaultValues: { name: '', email: '' },
    mode: 'onBlur',                          // the timing convention
  })

  async function onSubmit(values) {          // only runs if validation passed
    await signup(values)
    reset()
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} aria-invalid={!!errors.email} />
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <button disabled={isSubmitting || !isDirty}>
        {isSubmitting ? 'Creating…' : 'Create account'}
      </button>
    </form>
  )
}

// Typing re-renders nothing. Only error state changes cause renders.
4

Submission — Pending, Success and Failure

What happens between the click and the outcome is where forms are won or lost: no double submits, no lost input, and a result the user can actually see.

  • Disable and relabel the submit button while the request is in flight, setting the flag before the await
  • Enter in a text field submits too — disabling on pending covers every path to a double submit
  • On success, confirm what happened, refresh the affected data, and move the user forward
  • On failure never clear the form, map errors to fields where possible, and re-enable the button
  • Move focus to the error message so screen-reader users hear it, and autosave drafts for long forms
Disable, relabel, and hold the layout
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
  {isSubmitting ? 'Saving…' : 'Save changes'}
</button>

// Disabled alone is not enough feedback. A label change or spinner
// tells the user the click registered.

// Reserve the width so the button does not resize between the two
// labels, which reads as a jitter:
<span className="min-w-[8ch] inline-block text-center">…</span>

// Prevent the other paths to a double submit:
//   - Enter in a text field submits the form too
//   - a second click during the request
// Both are covered by disabling on isSubmitting, provided the flag
// is set before the await:
setSubmitting(true)
try { await save(values) } finally { setSubmitting(false) }

// For anything that creates a record, an idempotency key makes the
// duplicate harmless even if one slips through.
5

File Upload

A file input cannot be controlled, uploads need progress that fetch cannot report, and at any real size the file should not pass through your API at all.

  • A file input is always uncontrolled — you cannot set its value from state
  • Object URLs must be revoked on cleanup or the selected file stays in memory
  • fetch cannot report upload progress; XMLHttpRequest's upload.onprogress can
  • Client-side type and size checks are convenience only — the server must verify the real content
  • Production uploads go directly to storage via a short-lived signed URL, with the signing endpoint as the security boundary
Validate, preview with an object URL, revoke it
const [file, setFile] = useState(null)
const [preview, setPreview] = useState(null)

function onChange(e) {
  const f = e.target.files?.[0]
  if (!f) return

  if (!['image/jpeg', 'image/png', 'image/webp'].includes(f.type))
    return setError('Please choose a JPEG, PNG or WebP image')
  if (f.size > 5 * 1024 * 1024)
    return setError('Images must be under 5MB')

  setFile(f)
  setPreview(URL.createObjectURL(f))       // an object URL, not a data URL
}

// Object URLs must be revoked or the file stays in memory
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview) }, [preview])

<input type="file" accept="image/*" onChange={onChange} />
// accept filters the OS picker; it does not enforce anything.
// The extension and the MIME type are both attacker-controlled —
// the server must verify the real content.
6

Multi-Step Forms and Wizards

One state object, per-step validation, and a step in the URL. The hard parts are surviving a refresh and letting the user go backwards without losing anything.

  • Keep every answer in one object and merge each step's values rather than replacing them
  • Put the step in the URL so the back button, refresh and shared links all behave
  • Validate each step's own schema on leaving it, then validate the merged schema before the final submit
  • Returning to a previous step must show exactly what the user typed — pass the values back as defaults
  • Persist the draft but never persist passwords, card numbers or OTPs, and clear it when the flow completes
One values object, step in the URL
const STEPS = ['account', 'profile', 'goals', 'review']

function Onboarding() {
  const [params, setParams] = useSearchParams()
  const step = STEPS.indexOf(params.get('step') ?? 'account')
  const [values, setValues] = useState(() => loadDraft() ?? {})

  const goTo = (i) => setParams({ step: STEPS[i] })      // a history entry

  const next = (stepValues) => {
    setValues(v => ({ ...v, ...stepValues }))            // merge, never replace
    goTo(step + 1)
  }

  return (
    <>
      <Progress current={step} total={STEPS.length} />
      {step === 0 && <AccountStep defaults={values} onNext={next} />}
      {step === 1 && <ProfileStep defaults={values} onNext={next} onBack={() => goTo(0)} />}
      {step === 3 && <Review values={values} onSubmit={submitAll} />}
    </>
  )
}

// Step in the URL means: back button works, refresh keeps position,
// and a support conversation can say "go to ?step=goals".
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/react