Learning path · Level 9 · Tutorial 5

Build Controlled Forms in React

Build a labeled React form whose controls stay synchronized with state and whose local submission behavior preserves native validation.

  • Level: Intermediate
  • Duration: 20 min
  • 8 steps
Start tutorial Back to the path
Build Controlled Forms in React

What you will go through

  1. Recognize the Controlled-Control Contract Learn the contract that keeps React-controlled form values stable before binding individual controls. 2 min
  2. Bind a Labeled Text Field 3 min
  3. Control Selects and Checkboxes Bind the session selector and materials checkbox to their own state values with the prop and event property appropriate to each control. 3 min
  4. Type Extracted Change Handlers Extract element-specific React change handlers while keeping the Workshop planner controls controlled. 2 min
  5. Keep Numeric Drafts Editable Keep the Workshop planner seat count editable as a string, then convert it only when a local preview needs a number. 2 min
  6. Submit Locally Without Bypassing Validation Handle a valid form submission through the form, preserve native browser validation, and render an accessible local preview from state. 3 min
  7. Reset the Draft and Its Feedback 3 min
  8. Verify the Complete Controlled Form Run and verify a complete labeled controlled form in your own React project, including native validation, local-only feedback, keyboard submission, and state-driven reset. 3 min

What you will learn

  • Bind text, select, and checkbox controls to appropriately typed state.
  • Use inferred or explicit React event types to read control values.
  • Handle local submission without bypassing native constraints or claiming that data was stored remotely.
  • Reset controlled fields and render accessible completion feedback from state.

Before you start

  • Update Component State from User Events
  • Build Accessible Forms with Native Validation
  • Type Function Boundaries and Callbacks

Step 1 of 8

Recognize the Controlled-Control Contract

Learn the contract that keeps React-controlled form values stable before binding individual controls.

The controlled-control loop

One current value, one loop

A controlled control has one source of truth: React state.

  1. State supplies what the control displays.
  2. A user edit triggers a change handler.
  3. The handler updates state.
  4. React renders again with the new current value.

This loop means the displayed control and its state stay synchronized. In the Workshop planner, a typed attendee name will eventually be represented by string state, while a checked checkbox will be represented by boolean state.

A circular diagram showing state supplying an input display, a user edit, an update, and a refreshed display.

A controlled-control loop: state supplies the display, and an edit leads back to updated state.

Keep types stable from the first render

Current value versus initial default

Choose a mode for a control and keep that mode for its whole lifetime.

  • value and checked describe the current values of controlled controls.
  • defaultValue and defaultChecked provide an initial value for uncontrolled controls; after that, the browser manages the live value.

Do not switch a control between these modes after it first renders. In particular, do not pass null or undefined as a controlled value. A controlled text value is always a string, including ""; a controlled checked value is always a boolean.

Tip

Stable initial state

These initial Workshop planner states already use stable controlled-value types. The bindings and change handlers will be added in the next steps.

Workshop planner state

Workshop planner state in src/App.tsx

tsx
import { useState } from "react";

export default function App() {
  const [attendeeName, setAttendeeName] = useState("");
  const [session, setSession] = useState("");
  const [seatsDraft, setSeatsDraft] = useState("1");
  const [includeMaterials, setIncludeMaterials] = useState(false);

  return <main />;
}

Practice: classify control props

Check the two modes

Match each JSX prop to its role.

Match every prop with its role.

Tap an item and then its matching pair.

Practice: preserve controlled mode

Practice: choose a stable text initialization

Which initialization keeps attendeeName ready to be used as the current value of a controlled text field from its first render?

Step 2 of 8

Bind a Labeled Text Field

Follow the text-field loop

One state value, one displayed value

A controlled text field has one source of truth: React state. The input displays attendeeName; when the learner types, the change callback stores that exact string; React then renders the updated value back into the input.

For the Workshop planner, keep attendeeName as a string. The empty string is a valid starting value, and every edit—including spaces—can be stored unchanged.

The controlled text-field loop

Diagram showing typing in a text input, updating React state, and rendering the state value back into that input.

The displayed text comes from state, while each edit sends a new string back to state.

Bind the label, value, and edit

Implement the attendee field

In src/App.tsx, create the first controlled control. The inline callback receives its event type from the onChange prop, so TypeScript knows that event.currentTarget is this input element. Read its string with .value and pass it directly to the setter.

The native label connection remains intact: htmlFor and id use the same value. React controls the text value; the browser still provides the label and the required constraint.

A controlled attendee-name field

src/App.tsx — Workshop planner

tsx
import { useState } from "react";

export default function App() {
  const [attendeeName, setAttendeeName] = useState("");

  return (
    <main>
      <h1>Workshop planner</h1>
      <form>
        <label htmlFor="attendee-name">Attendee name</label>
        <input
          id="attendee-name"
          name="attendee-name"
          type="text"
          required
          value={attendeeName}
          onChange={(event) => {
            setAttendeeName(event.currentTarget.value);
          }}
        />
      </form>
    </main>
  );
}

Warning

Keep the contract paired

Do not replace the current-value binding with an uncontrolled default. A controlled control needs its displayed value and its change update to work together for its entire lifetime.

Practice: repair the edit loop

Complete the controlled binding

Complete the three bindings for the controlled attendee-name field. A field that receives a current value but does not successfully update its state will render the old value again, so typing appears not to work normally.

tsx
<label htmlFor="attendee-name">Attendee name</label>
<input
  id="attendee-name"
  name="attendee-name"
  type="text"
  required
  ={attendeeName}
  ={(event) => {
    (event.currentTarget.value);
  }}
/>

Step 3 of 8

Control Selects and Checkboxes

Bind the session selector and materials checkbox to their own state values with the prop and event property appropriate to each control.

Bind the session selector

Control the session selector

A select has one string value

In Workshop planner, session starts as "". That empty string matches the first option, so the select begins with Choose a session displayed.

Bind the select with value={session}. Each option's value is the value React stores—not necessarily the text a person sees. When the learner selects an option, read event.currentTarget.value; it is a string.

Two controls, two value shapes

A select control is connected to a string state value, while a checked checkbox is connected to a boolean state value.

The selector synchronizes a string value; the checkbox synchronizes a boolean checked state.

Session binding

Add the session state and this labeled controlled select in src/App.tsx. The empty-value option represents no session selection yet.

tsx
const [session, setSession] = useState("");

<label htmlFor="session">Session</label>
<select
  id="session"
  name="session"
  required
  value={session}
  onChange={(event) => setSession(event.currentTarget.value)}
>
  <option value="">Choose a session</option>
  <option value="morning">Morning</option>
  <option value="afternoon">Afternoon</option>
</select>

Bind the materials checkbox

Use checked for the checkbox

A checkbox is different

A checkbox has a string value attribute too, but that does not describe whether it is selected. Its displayed on/off state comes from checked, and event.currentTarget.checked is a boolean.

Keep this state separate from session: changing materials must not change the session, and changing the session must not change materials.

Materials binding

Add a separate boolean state and the optional materials control. The inline handler receives the appropriate input event type from JSX.

tsx
const [includeMaterials, setIncludeMaterials] = useState(false);

<label htmlFor="include-materials">Include materials</label>
<input
  id="include-materials"
  name="include-materials"
  type="checkbox"
  checked={includeMaterials}
  onChange={(event) => setIncludeMaterials(event.currentTarget.checked)}
/>

Tip

Keep the pairing exact

Use value for the single-select's current string. Use checked for the checkbox's current boolean. Both handlers read from event.currentTarget, but they read the property that matches the control.

Check the two contracts

Match control contracts

Match each Workshop planner control detail to its correct partner.

Tap an item and then its matching pair.

Apply both bindings

Complete the bindings

Complete both controlled bindings.

tsx
<label htmlFor="session">Session</label>
<select
  id="session"
  name="session"
  required
  ={}
  onChange={(event) => setSession(event.currentTarget.)}
>
  <option value="">Choose a session</option>
  <option value="morning">Morning</option>
  <option value="afternoon">Afternoon</option>
</select>

<label htmlFor="include-materials">Include materials</label>
<input
  id="include-materials"
  name="include-materials"
  type="checkbox"
  ={}
  onChange={(event) => setIncludeMaterials(event.currentTarget.)}
/>

Step 4 of 8

Type Extracted Change Handlers

Extract element-specific React change handlers while keeping the Workshop planner controls controlled.

Give standalone handlers an event type

Why extracted handlers need a type

Inline JSX handlers receive their event type from the prop they are attached to. Once you move that callback into a standalone function, it no longer has that contextual type. Add an explicit ChangeEvent<...> annotation so TypeScript knows what event.currentTarget is.

The generic identifies the actual control element. That lets you read its appropriate property without a cast or any.

Typed event paths

A diagram connecting a text input, select, and checkbox to element-specific change handlers and their currentTarget values.

An element-specific event generic gives currentTarget the matching DOM element type.

Preserve each control's behavior

Extract the Workshop planner handlers

Import React event types with a type-only import, then annotate each extracted handler for the element that triggers it.

tsx
import type { ChangeEvent } from "react";

function handleNameChange(event: ChangeEvent<HTMLInputElement>) {
  setAttendeeName(event.currentTarget.value);
}

function handleSessionChange(event: ChangeEvent<HTMLSelectElement>) {
  setSession(event.currentTarget.value);
}

function handleMaterialsChange(event: ChangeEvent<HTMLInputElement>) {
  setIncludeMaterials(event.currentTarget.checked);
}

Keep the bindings

Use the named handlers in the existing controlled controls. Their state bindings stay the same; only the callback is now named.

`currentTarget` is typed from the generic in the handler parameter.
ControlHandler event typeRead from currentTargetState value type
Attendee nameChangeEvent<HTMLInputElement>valuestring
SessionChangeEvent<HTMLSelectElement>valuestring
Include materialsChangeEvent<HTMLInputElement>checkedboolean

Practice: event generics and currentTarget

Type the two handlers

Complete the extracted handlers so TypeScript identifies each control and its string value.

tsx
function handleNameChange(event: ChangeEvent<>) {
  setAttendeeName(event.currentTarget.);
}

function handleSessionChange(event: ChangeEvent<>) {
  setSession(event.currentTarget.);
}

Step 5 of 8

Keep Numeric Drafts Editable

Keep the Workshop planner seat count editable as a string, then convert it only when a local preview needs a number.

Keep the editable representation

Editable value versus numeric value

A numeric control still has a string draft

type="number" changes the browser’s input UI and native constraints, but event.currentTarget.value is still a string. Keep the editable seat count in seatsDraft.

That gives the draft an important state: it can be empty while the learner is editing. An empty draft is not the same thing as the numeric value zero.

The seat count has two representations

Diagram showing a controlled number input keeping string state while it is edited, then converting the string to a number later.

While editing, the control and seatsDraft share a string value. Conversion belongs at the later local-submission boundary.

Defer the conversion

Bind the Seats draft as a string

Place this state line near the other state declarations in the existing Workshop planner App component. Bind the Seats control to that state and store the unmodified value from currentTarget.

A controlled number input may temporarily be blank. If every edit were converted with Number, a cleared field would become 0, which changes what the learner typed. Keep only the draft while editing; do not add a second synchronized numeric state.

At the later accepted local-submission boundary, derive the number once with const seats = Number(seatsDraft);.

Seats state and controlled field

Inside src/App.tsx, in the existing Workshop planner component

tsx
const [seatsDraft, setSeatsDraft] = useState("1");

<label htmlFor="seats">Seats</label>
<input
  id="seats"
  name="seats"
  type="number"
  min={1}
  max={6}
  step={1}
  required
  value={seatsDraft}
  onChange={(event) => setSeatsDraft(event.currentTarget.value)}
/>

Trace the draft

These are different moments in the same interaction.

The editable value remains text until the deliberate conversion point.
User editseatsDraftNumber(seatsDraft)
Initial field"1"1
Types 2"2"2
Clears field""0

Practice: preserve the draft

Complete the Seats binding

Complete the controlled Seats binding so every edit, including a cleared edit, stays in the editable draft.

tsx
<label htmlFor="seats">Seats</label>
<input
  id="seats"
  name="seats"
  type="number"
  min={1}
  max={6}
  step={1}
  required
  value=
  onChange={(event) => }
/>

Apply the model

Explain the cleared-field trace

After the learner clears the initialized Seats field, write a short two-step trace. State what the change handler stores, then contrast that with what immediate numeric conversion would produce. Conclude why conversion should wait for the local-submission boundary.

Write at least 90 characters (0/90).

Step 6 of 8

Submit Locally Without Bypassing Validation

Handle a valid form submission through the form, preserve native browser validation, and render an accessible local preview from state.

Let native validation gate submission

Let native validation gate submission

Attach submission behavior to the form’s onSubmit, not to a submit button click. That gives a click on Preview locally and keyboard submission the same route.

With normal browser submission, native constraints run first. An empty required control or an invalid Seats value is rejected by the browser, so the submit handler is not reached. When the form is valid, React receives the submit event. The handler can then keep the page in place and create a local preview.

The valid submission path

Diagram showing invalid form data stopped by browser validation and valid form data continuing to a React submit handler and a status message.

Native validation decides whether the submit handler is reached; only a valid form continues to the local preview.

Warning

Keep the browser in the flow

Do not add noValidate, and do not move submission work to a submit button’s onClick. Keep the browser’s constraints and normal keyboard behavior in the form flow.

Call event.preventDefault() only inside handleSubmit. At that point, native validation has accepted the normal submission, and preventing the default stops navigation—not validation.

Handle an accepted local submission

Add submission state and behavior

First, update the imports at the top level of src/App.tsx. ES module imports stay outside App.

Then, inside the existing App component, add feedback alongside the other state declarations and place handleSubmit before return. The handler converts the accepted string draft to seats, then stores one truthful in-memory local preview. It does not send or save anything.

File-level imports

At the top of src/App.tsx, keep the runtime import and update the type-only import.

tsx
import { useState } from "react";
import type { ChangeEvent, FormEvent } from "react";

State and handler inside App

Inside App, alongside the existing state and handlers, add the feedback state and valid-submission handler.

tsx
const [feedback, setFeedback] = useState("");

function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();

  const seats = Number(seatsDraft);

  setFeedback(
    `Last local preview: ${attendeeName}, ${session}, ${seats} seat(s), materials: ${includeMaterials}. Nothing was sent or saved.`,
  );
}

Connect the form and status region

In the returned JSX, connect the existing form to the handler. Keep the status region mounted inside the form so React can render the feedback string.

tsx
<form onSubmit={handleSubmit}>
  {/* Existing labeled controlled controls stay here. */}
  <button type="submit">Preview locally</button>
  <p role="status">{feedback}</p>
</form>

Practice the submission boundary

Complete the submit handler

Complete the valid-submission handler.

tsx
function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.();

  const seats = (seatsDraft);

  (
    `Last local preview: ${attendeeName}, ${session}, ${seats} seat(s), materials: ${includeMaterials}. Nothing was sent or saved.`,
  );
}

Verify the validation-to-feedback flow

Trace validation to feedback

Put the valid local-submission path in order. If the browser finds an invalid constraint at the first stage, none of the later stages occur.

  1. Browser dispatches the form submit event to React.
  2. The handler converts the accepted draft and updates feedback.
  3. Browser checks the form's native constraints.
  4. handleSubmit prevents default navigation.

Step 7 of 8

Reset the Draft and Its Feedback

Understand reset behavior

A reset starts a new draft

A successful local preview creates a feedback snapshot. It reflects the values at submission time.

If the learner then edits a controlled control, those draft values change—but the previous feedback does not. No new submission occurred, so feedback keeps its earlier text.

Reset form is a separate user action. It restores every React-owned field to its initial value and clears the feedback string.

Feedback is a submitted snapshot

A three-state diagram showing that editing a submitted draft keeps the old feedback, while reset restores the draft and empties the status region.

Later draft edits do not rewrite the last local preview. Reset clears it while the status region stays mounted.

Implement the reset action

Reset state, not just the DOM

Controlled controls receive their displayed values from React state. Therefore, reset those state values in the reset button's event handler.

A native DOM reset alone does not reset React state. On a later render, React can apply the state-owned values again. Use an explicit button with type="button" so this action does not submit the form.

Add the reset handler and button

Add this inside App, alongside the existing submit handler. Place the button inside the form.

tsx
function handleReset() {
  setAttendeeName("");
  setSession("");
  setSeatsDraft("1");
  setIncludeMaterials(false);
  setFeedback("");
}

// Inside the form, near the submit button:
<button type="button" onClick={handleReset}>
  Reset form
</button>

Keep completion feedback accessible

Keep the existing status region mounted:

<p role="status">{feedback}</p>

setFeedback("") makes the region empty after reset. It does not remove the region or mutate the DOM directly.

Complete the handler

Practice: restore the controlled state

Complete the reset handler for the Workshop planner.

tsx
function handleReset() {
  setAttendeeName();
  setSession();
  setSeatsDraft();
  setIncludeMaterials();
  setFeedback();
}

Verify the reset contract

Apply the complete reset contract

Without looking back, write the five setter calls that belong in handleReset.

Then predict both outcomes:

  1. After a valid local preview, what happens to the displayed feedback if the learner edits a field?
  2. What happens to that feedback after the learner activates Reset form?

Also state why the reset button must not submit the form.

Write at least 120 characters (0/120).

Step 8 of 8

Verify the Complete Controlled Form

Run and verify a complete labeled controlled form in your own React project, including native validation, local-only feedback, keyboard submission, and state-driven reset.

Build the state-to-control loop

Assemble the complete component

Replace src/App.tsx in your existing React + TypeScript project with the following three contiguous sections, in order. Keep the imports at the top of the file, add no dependencies, and do not add noValidate.

The diagram is the integration contract you will verify: state supplies every displayed controlled value; native validation gates submission; a valid submission produces an in-memory local preview; reset restores the initial draft.

Controlled-form flow

Diagram showing state flowing to four controlled form fields, browser validation before local submission, feedback in a status region, and reset returning state to initial values.

State controls the displayed draft. Native constraints decide whether the local submit handler runs; reset restores React-owned values.

src/App.tsx — section 1 of 3

Copy this as the first section of src/App.tsx.

tsx
import { useState } from "react";
import type { ChangeEvent, FormEvent } from "react";

export default function App() {
  const [attendeeName, setAttendeeName] = useState("");
  const [session, setSession] = useState("");
  const [seatsDraft, setSeatsDraft] = useState("1");
  const [includeMaterials, setIncludeMaterials] = useState(false);
  const [feedback, setFeedback] = useState("");

  function handleNameChange(event: ChangeEvent<HTMLInputElement>) {
    setAttendeeName(event.currentTarget.value);
  }

  function handleSessionChange(event: ChangeEvent<HTMLSelectElement>) {
    setSession(event.currentTarget.value);
  }

  function handleMaterialsChange(event: ChangeEvent<HTMLInputElement>) {
    setIncludeMaterials(event.currentTarget.checked);
  }

Submit a valid draft locally

Add submission and reset behavior

Place this section immediately after section 1. The form owns submission through onSubmit, so clicking Preview locally and submitting with the keyboard use the same behavior. The browser checks required fields and the Seats constraints before this handler runs.

On a valid submission, preventDefault() prevents navigation, Number converts the accepted draft, and feedback stores one local preview snapshot. The reset handler restores every state value rather than changing the DOM directly.

src/App.tsx — section 2 of 3

Paste this directly below section 1.

tsx
  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const seats = Number(seatsDraft);

    setFeedback(
      `Last local preview: ${attendeeName}, ${session}, ${seats} seats, materials: ${includeMaterials}. Nothing was sent or saved.`,
    );
  }

  function handleReset() {
    setAttendeeName("");
    setSession("");
    setSeatsDraft("1");
    setIncludeMaterials(false);
    setFeedback("");
  }

  return (
    <main>
      <h1>Workshop planner</h1>
      <form onSubmit={handleSubmit}>
        <label htmlFor="attendee-name">Attendee name</label>
        <input
          id="attendee-name"
          name="attendee-name"
          type="text"
          required
          value={attendeeName}
          onChange={handleNameChange}
        />

        <label htmlFor="session">Session</label>
        <select
          id="session"
          name="session"
          required
          value={session}
          onChange={handleSessionChange}
        >
          <option value="">Choose a session</option>
          <option value="morning">Morning</option>
          <option value="afternoon">Afternoon</option>
        </select>

Verify the integrated behavior

Finish and run the component

Paste the final section directly below section 2, then save the file and use your project's usual local development command. The expected rendered states are attached to this completed, runnable component: compare your initial form and your valid local preview with them.

src/App.tsx — section 3 of 3

Paste this directly below section 2 to complete src/App.tsx.

tsx

        <label htmlFor="seats">Seats</label>
        <input
          id="seats"
          name="seats"
          type="number"
          min={1}
          max={6}
          step={1}
          required
          value={seatsDraft}
          onChange={(event) => setSeatsDraft(event.currentTarget.value)}
        />

        <label htmlFor="include-materials">Include materials</label>
        <input
          id="include-materials"
          name="include-materials"
          type="checkbox"
          checked={includeMaterials}
          onChange={handleMaterialsChange}
        />

        <button type="submit">Preview locally</button>
        <button type="button" onClick={handleReset}>
          Reset form
        </button>
        <p role="status">{feedback}</p>
      </form>
    </main>
  );
}
Expected output
Initial render
Workshop planner
Attendee name: [empty]
Session: [Choose a session]
Seats: [1]
Include materials: [unchecked]
[Preview locally] [Reset form]
Status: [empty]

After entering Alex, selecting Morning, entering 2, checking Include materials, and submitting
Workshop planner
Attendee name: [Alex]
Session: [Morning]
Seats: [2]
Include materials: [checked]
[Preview locally] [Reset form]
Status: Last local preview: Alex, morning, 2 seats, materials: true. Nothing was sent or saved.

Rendered controls are represented as text here. Native browser validation bubbles and styling vary by browser; for invalid attempts, the important result is that no new status message appears and the page does not navigate.

Verification checklist

Use this checklist in the local browser. Begin with an invalid attempt before creating a valid preview, so you can tell whether an invalid attempt changed the status region.

Behavior to confirm in the local browser
ActionExpected behavior
Submit with a required field emptyThe browser blocks submission; no new local preview appears.
Use Seats outside 1–6The browser blocks submission; the submit handler does not create a new preview.
Use Alex, Morning, 2, and checked materialsA valid submit stays on the page and updates the status region with the local preview.
Press Enter with a valid formThe form follows the same submit behavior as the submit button.
Edit a field after a valid previewThe prior feedback remains a submitted snapshot until another valid submission or reset.
Choose Reset formInitial controlled values return and the status region becomes empty.
Reload after a valid previewThe preview disappears because no persistence exists.

Controlled-form contract

The complete component keeps the controlled-form contract intact. Strings control the text field, select, and editable numeric draft; a boolean controls the checkbox. Change handlers read currentTarget and update the matching state. Native constraints gate onSubmit; only valid submissions create a local preview. Reset restores state and clears the still-mounted status region.

Tip

Key takeaways

Use state as the source of truth for displayed control values and completion feedback.

Apply and complete

Report your local verification

Run the checklist in your own project. First attempt submission with an empty required control and with an out-of-range seat count. Then use Alex, the Morning session, a seat draft of "2", and checked materials; submit with the keyboard. Edit one field after the preview, reset the form, then create another valid preview and reload the page.

In 3–6 sentences, report what you observed for blocked submissions, keyboard submission, the local preview, edits after submission, reset, and reload.

Write at least 180 characters (0/180).

Complete

Congratulations! You completed: Build Controlled Forms in React

You verified a complete controlled form: its controls, validation-preserving local submission, feedback, and reset behavior are driven by state.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate