React Basics #9: Working with forms (controlled inputs)

8 min read

Up through last time we covered every core building block of React: components, props, state, events, and conditional/list rendering. From this article on, we move into more practical patterns. The first topic is forms, which appear in almost every app.

Two ways to handle input elements #

There are broadly two approaches to handling input elements in React.

  • Controlled component — the source of truth for the input value lives in React state, and the input on screen mirrors that state
  • Uncontrolled component — the input value lives in the DOM itself, and you grab it with ref when you need it

In React, controlled components are the standard, and that’s what we’ll focus on. We’ll revisit the uncontrolled approach later when we cover refs.

What is a controlled component? #

You’ve already used this pattern in #6 and #8. The key is to pair value with onChange.

src/SimpleInput.jsx
import { useState } from 'react';

function SimpleInput() {
  const [text, setText] = useState('');

  return (
    <div>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <p>Input value: {text}</p>
    </div>
  );
}

export default SimpleInput;

Here’s the flow:

  1. The user presses a key → the browser fires a change event
  2. The onChange handler pulls the new value via e.target.value and reflects it in state with setText
  3. State changes, the component re-renders
  4. On re-render, <input value={text}> shows the updated value

On the surface, it’s just a normal input that displays what you type. But internally a full cycle of going through state and back to the screen runs every time. That’s a controlled component.

Why so much trouble? #

You might wonder, “The browser already remembers the input. Why bother routing through state and re-rendering?” Right — if you leave it alone, the browser keeps the value (that’s the uncontrolled approach). But there’s a lot to gain by going controlled.

  • Live transformation/validation becomes easy (length limits, auto-uppercasing, format checks, etc.)
  • You can make two input fields stay in sync (changing one updates the other)
  • You can share the input value with other components via state (the pattern we’ll cover in #11)
  • You can express the submit button’s enabled condition directly from input state (you saw this in #7)

In most form scenarios, controlled is more intuitive and more powerful.

textarea #

textarea uses value/onChange just like input. In HTML you’d put the value as a child (<textarea>some text</textarea>), but in React you handle it via the value attribute.

src/MemoForm.jsx
import { useState } from 'react';

function MemoForm() {
  const [memo, setMemo] = useState('');

  return (
    <textarea
      value={memo}
      onChange={(e) => setMemo(e.target.value)}
      rows={5}
    />
  );
}

export default MemoForm;

select #

A dropdown (<select>) follows the same pattern. The selected option’s value comes through e.target.value.

src/CategoryPicker.jsx
import { useState } from 'react';

function CategoryPicker() {
  const [category, setCategory] = useState('frontend');

  return (
    <select value={category} onChange={(e) => setCategory(e.target.value)}>
      <option value="frontend">Frontend</option>
      <option value="backend">Backend</option>
      <option value="devops">DevOps</option>
    </select>
  );
}

Each <option>’s value matches against the state value. The initial state ('frontend') is the option that’s selected initially.

checkbox #

A checkbox’s value is a boolean (checked or not), not a string. So you use checked instead of value, and e.target.checked instead of e.target.value.

src/AgreeCheckbox.jsx
import { useState } from 'react';

function AgreeCheckbox() {
  const [agreed, setAgreed] = useState(false);

  return (
    <label>
      <input
        type="checkbox"
        checked={agreed}
        onChange={(e) => setAgreed(e.target.checked)}
      />
      I agree to the terms
    </label>
  );
}

radio #

Radio buttons are a group where you pick one of several. Tie them together with the same name, and express checked as state === the option's value.

src/PaymentRadio.jsx
import { useState } from 'react';

function PaymentRadio() {
  const [payment, setPayment] = useState('card');

  return (
    <div>
      <label>
        <input
          type="radio"
          name="payment"
          value="card"
          checked={payment === 'card'}
          onChange={(e) => setPayment(e.target.value)}
        />
        Card
      </label>
      <label style={{ marginLeft: '12px' }}>
        <input
          type="radio"
          name="payment"
          value="bank"
          checked={payment === 'bank'}
          onChange={(e) => setPayment(e.target.value)}
        />
        Bank transfer
      </label>
    </div>
  );
}

Managing many fields in one object #

When there are lots of form fields, calling useState many times gets tedious. A common pattern is to bundle them into a single object.

src/SignupForm.jsx
import { useState } from 'react';

function SignupForm() {
  const [form, setForm] = useState({
    name: '',
    email: '',
    password: '',
    agreed: false,
  });

  function handleChange(e) {
    const { name, type, value, checked } = e.target;
    setForm(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }));
  }

  return (
    <form>
      <input name="name" value={form.name} onChange={handleChange} placeholder="Name" />
      <input name="email" value={form.email} onChange={handleChange} placeholder="Email" />
      <input name="password" type="password" value={form.password} onChange={handleChange} placeholder="Password" />
      <label>
        <input name="agreed" type="checkbox" checked={form.agreed} onChange={handleChange} />
        Accept terms
      </label>
    </form>
  );
}

export default SignupForm;

Two key tricks:

  • Give each input a name attribute to identify which field it is
  • Use setForm(prev => ({ ...prev, [name]: ... })) to update only part of the object (the pattern from #5)

A single handler can manage multiple fields and the code stays clean. If per-field validation logic gets complex, splitting back into separate useState calls might end up better. There’s no fixed answer; pick what fits the situation.

Handling form submission #

A recap from #6. Use <form>’s onSubmit and call e.preventDefault() to block the page refresh — that’s the standard.

src/SignupForm.jsx
function handleSubmit(e) {
  e.preventDefault();
  if (!form.agreed) {
    alert('Please accept the terms.');
    return;
  }
  console.log('signup info:', form);
  // in practice you'd send this to a server here
}

return (
  <form onSubmit={handleSubmit}>
    {/* ... */}
    <button type="submit">Sign up</button>
  </form>
);

Pressing <button type="submit"> or hitting Enter inside an input submits the form. A button’s default type is submit, so it acts as a submit button inside a form even without specifying it explicitly — but it’s a good habit to specify it to avoid confusion.

Cleaning up input #

One of the strengths of controlled components is that you can transform the input freely. Just touch the value right before calling set.

auto-uppercase
<input
  value={code}
  onChange={(e) => setCode(e.target.value.toUpperCase())}
/>
digits only
<input
  value={phone}
  onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
/>
max length
<input
  value={message}
  onChange={(e) => setMessage(e.target.value.slice(0, 100))}
/>

Because what’s on screen always matches the state, the user sees the result immediately.

Try it yourself #

Let’s build a signup form. It’s a comprehensive example bringing several kinds of inputs into one screen.

src/SignupForm.jsx:

src/SignupForm.jsx
import { useState } from 'react';

function SignupForm() {
  const [form, setForm] = useState({
    name: '',
    email: '',
    age: '',
    gender: 'female',
    interests: {
      frontend: false,
      backend: false,
      design: false,
    },
    bio: '',
    agreed: false,
  });
  const [submitted, setSubmitted] = useState(null);

  function handleChange(e) {
    const { name, type, value, checked } = e.target;
    setForm(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }));
  }

  function handleInterestChange(e) {
    const { name, checked } = e.target;
    setForm(prev => ({
      ...prev,
      interests: { ...prev.interests, [name]: checked },
    }));
  }

  function handleSubmit(e) {
    e.preventDefault();
    if (!form.agreed) {
      alert('Please accept the terms.');
      return;
    }
    setSubmitted(form);
  }

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px', maxWidth: '400px' }}>
      <h2>Sign up</h2>
      <form onSubmit={handleSubmit}>
        <div>
          <label>Name: </label>
          <input name="name" value={form.name} onChange={handleChange} />
        </div>
        <div>
          <label>Email: </label>
          <input name="email" type="email" value={form.email} onChange={handleChange} />
        </div>
        <div>
          <label>Age: </label>
          <input
            name="age"
            type="text"
            value={form.age}
            onChange={(e) => setForm(prev => ({
              ...prev,
              age: e.target.value.replace(/\D/g, ''),
            }))}
          />
        </div>
        <div>
          <label>Gender: </label>
          <label>
            <input type="radio" name="gender" value="female" checked={form.gender === 'female'} onChange={handleChange} />
            Female
          </label>
          <label style={{ marginLeft: '8px' }}>
            <input type="radio" name="gender" value="male" checked={form.gender === 'male'} onChange={handleChange} />
            Male
          </label>
        </div>
        <div>
          <label>Interests: </label>
          <label>
            <input type="checkbox" name="frontend" checked={form.interests.frontend} onChange={handleInterestChange} />
            Frontend
          </label>
          <label style={{ marginLeft: '8px' }}>
            <input type="checkbox" name="backend" checked={form.interests.backend} onChange={handleInterestChange} />
            Backend
          </label>
          <label style={{ marginLeft: '8px' }}>
            <input type="checkbox" name="design" checked={form.interests.design} onChange={handleInterestChange} />
            Design
          </label>
        </div>
        <div>
          <label>Bio: </label>
          <textarea name="bio" value={form.bio} onChange={handleChange} rows={3} />
        </div>
        <div>
          <label>
            <input type="checkbox" name="agreed" checked={form.agreed} onChange={handleChange} />
            I agree to the terms
          </label>
        </div>
        <button type="submit" disabled={!form.agreed} style={{ marginTop: '8px' }}>
          Sign up
        </button>
      </form>

      {submitted && (
        <pre style={{ marginTop: '16px', background: '#f4f4f4', padding: '8px' }}>
          {JSON.stringify(submitted, null, 2)}
        </pre>
      )}
    </div>
  );
}

export default SignupForm;

Wire it up in src/App.jsx.

src/App.jsx
import SignupForm from './SignupForm';

function App() {
  return <SignupForm />;
}

export default App;

Several kinds of inputs all run as controlled components. The age input automatically accepts digits only, and the submit button is enabled only when the terms are accepted. Submitting prints the input as JSON on screen.

Tip
For larger real-world forms, people often reach for form libraries like React Hook Form or Formik. They come with validation, error messages, and performance optimizations baked in, and shrink the code in big forms considerably. But this Basics series sticks to React’s built-in features without libraries — libraries themselves are built on top of the controlled pattern, so once you have the basics, picking up any library will be quick.

Wrapping up #

In this article we looked at the canonical form pattern: controlled components. The essentials:

  • Tie input to state with the value/onChange pair
  • Checkbox: checked/e.target.checked; radio: name + value + checked={state===value}
  • Bundling many fields in one object, distinguished by name, is a common pattern
  • Submit with <form onSubmit> + e.preventDefault()
  • Controlled’s strengths: live transformation/validation/sync

So far, the components we’ve built have all started and finished their work inside themselves. But real apps need to interact with the outside world — fetching data from a server, setting timers, using browser APIs, and so on. In the next article, “React Basics #10: useEffect,” we’ll learn the standard tool for handling these side effects, the useEffect hook.

X