RizTech Academy logo
RizTech Academy
React FundamentalsLesson 4 of 730 min

Events and controlled forms

Interfaces exist to be typed into. Forms in React work differently from plain HTML, and the difference is worth understanding rather than memorising.

Events

<button onClick={() => console.log("clicked")}>Add to cart</button>

camelCase name, a function as the value. Note it is the function itself, not a call:

<button onClick={handleClick}>      // correct
<button onClick={handleClick()}>    // calls it during render

The second runs handleClick while rendering and passes its return value as the handler. Symptom: the thing happens immediately on page load and never on click. Common enough to recognise instantly.

When you need to pass an argument, wrap it:

<button onClick={() => addToCart(product.id)}>

That creates a new function per render, which is fine and not worth optimising until profiling says otherwise.

The event object, when you need it:

function handleSubmit(event: React.FormEvent) {
  event.preventDefault();
  // ...
}

preventDefault() stops the browser's default behaviour — for a form, a full page reload. Forget it and the page refreshes, the state resets, and it looks like nothing happened.

Controlled inputs

In plain HTML the input owns its value. In React you usually take that over:

"use client";

import { useState } from "react";

export function SearchBox() {
  const [query, setQuery] = useState("");

  return (
    <input
      value={query}
      onChange={(event) => setQuery(event.target.value)}
      placeholder="Search products"
    />
  );
}

value comes from state; onChange writes back to it. That loop is what "controlled" means, and it is why typing works at all.

Set value without onChange and the field becomes read-only — React keeps resetting it to the state value. If typing does nothing, that is why.

The payoff is that the value is available to you as it changes:

<p>{query.length}/50</p>
<button disabled={query.trim() === ""}>Search</button>

Form submission

export function AddressForm({ onSave }: { onSave: (a: Address) => void }) {
  const [form, setForm] = useState({ line1: "", city: "", pincode: "" });
  const [errors, setErrors] = useState<Record<string, string>>({});

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const { name, value } = event.target;
    setForm((current) => ({ ...current, [name]: value }));
  }

  function handleSubmit(event: React.FormEvent) {
    event.preventDefault();

    const found: Record<string, string> = {};
    if (!form.line1.trim()) found.line1 = "Address is required";
    if (!/^\d{6}$/.test(form.pincode)) found.pincode = "Pincode must be 6 digits";

    setErrors(found);
    if (Object.keys(found).length > 0) return;

    onSave(form);
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <input name="line1" value={form.line1} onChange={handleChange} />
      {errors.line1 && <p className="text-red-600">{errors.line1}</p>}

      <input name="pincode" value={form.pincode} onChange={handleChange} />
      {errors.pincode && <p className="text-red-600">{errors.pincode}</p>}

      <button type="submit">Save address</button>
    </form>
  );
}

Several things worth pulling out.

One handleChange for every field, using name and a computed key [name]: value. Beats writing a handler per input.

The function form of setForm, because the new value depends on the old.

onSubmit on the form, not onClick on the button. That way Enter in a field submits too, which users expect.

type="submit" on the button. The default type inside a form is submit, but being explicit prevents a type="button" elsewhere breaking Enter.

Errors as state, rendered next to their field.

Do not disable the submit button

<button type="submit" disabled={isSubmitting}>

Reasonable-looking and risky: a disabled button does not submit, and whether React's re-render lands before or after the browser's default action is not something you control. On desktop it usually works; on a phone it can leave the button saying "Saving…" forever with nothing sent.

Show the pending state without disabling:

<button type="submit" aria-busy={isSubmitting}
        className={isSubmitting ? "pointer-events-none opacity-70" : ""}>
  {isSubmitting ? "Saving…" : "Save address"}
</button>

Prevent the duplicate in the handler instead, where you control the ordering:

function handleSubmit(event: React.FormEvent) {
  event.preventDefault();
  if (isSubmitting) return;
  setIsSubmitting(true);
  // ...
}

Other input types

<input type="checkbox" checked={agreed}
       onChange={(e) => setAgreed(e.target.checked)} />

<select value={category} onChange={(e) => setCategory(e.target.value)}>
  <option value="grains">Grains</option>
</select>

<textarea value={note} onChange={(e) => setNote(e.target.value)} />

Checkboxes use checked and event.target.checked. Everything else uses value. Selects and textareas are controlled the same way as text inputs — note that <textarea> takes value rather than children, unlike HTML.

Numbers arrive as strings

<input type="number" value={quantity}
       onChange={(e) => setQuantity(Number(e.target.value))} />

event.target.value is always a string, even for type="number". Without Number(), quantity + 1 gives "11" rather than 11 — the same trap as Python's input(), in a different language.

Watch out: Number("") is 0, not NaN, so clearing the field silently gives you zero. Handle the empty case explicitly if it matters.

Uncontrolled inputs

You can let the DOM own the value:

<form onSubmit={(e) => {
  e.preventDefault();
  const data = new FormData(e.currentTarget);
  console.log(data.get("email"));
}}>
  <input name="email" defaultValue="" />
</form>

defaultValue rather than value, and you read everything on submit. Simpler when you do not need the value as it changes, and it is exactly what server actions use in module 3.

Controlled when you need the value while typing — live validation, character counts, dependent fields. Uncontrolled when you only need it at the end.

Check your work

Why onClick={handleClick()} fires on load: it calls the function during render and passes its return value as the handler.

Why removing onChange stops you typing: with value set and nothing writing back, React resets the field to the state value on every keystroke.

What preventDefault stops: the browser's default form submission, which reloads the page and resets all state.

Why onSubmit on the form rather than onClick on the button: pressing Enter in a field submits the form, and that only triggers onSubmit.

Why not to disable a submit button: a disabled button does not submit, and the ordering of React's re-render against the browser's default action is not yours to control. Show pending state without disabling and guard in the handler.

Why type="number" still gives a string: event.target.value is always a string. Number() converts it — and note Number("") is 0, so clearing the field gives zero rather than nothing.

Controlled or uncontrolled: controlled when you need the value while typing; uncontrolled with FormData when you only need it on submit.

Practice

  1. Build a search box as a controlled input. Show the character count live.
  2. Remove onChange and confirm you cannot type.
  3. Write onClick={handleClick()} and observe it firing on load.
  4. Build the address form with validation on submit.
  5. Remove preventDefault and watch the page reload.
  6. Replace onSubmit with onClick on the button, then press Enter in a field and notice nothing happens.
  7. Add a submitting state without disabling the button.
  8. Add a type="number" quantity field. Add 1 to it without converting, see the string concatenation, then fix it. Clear the field and note it becomes 0.
  9. Rewrite the form uncontrolled with FormData and compare.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship