| |

React 27 ⚛️ Handling Multiple Inputs with One Handler

A form with ten fields can have ten useState calls and ten onChange handlers. That is ten opportunities to mistype a variable name, ten places to update when a field is added, and ten blocks of nearly identical code. The alternative is a single state object and a single change handler that routes each event to the correct field by reading the input’s name attribute. This is the standard pattern for forms with more than a handful of fields, and it appears in nearly every React codebase.

This chapter covers the single-handler pattern in full. You will learn how to structure the state object, how to write the generic handler, how to handle the differences between text inputs, checkboxes, and other field types, and how to scale the pattern to nested objects and dynamic fields. You will also see the pitfalls that produce stale state, the naming conventions that keep the handler readable, and the cases where separate state is still the better choice.

Key point: A single change handler reads three things from the event: e.target.name, e.target.value, and e.target.type. The name identifies which field changed. The value is the new value. The type distinguishes checkboxes—which report their state through checked rather than value—from other inputs. The handler updates one property of a state object using a computed property name.


Why the single-handler pattern exists

The repetition problem. Each field in a form needs a state variable and a handler that updates it. Writing them individually is repetitive. When a field is added, removed, or renamed, every place it appears must be updated. A single state object and a single handler reduce the surface area: one object, one function, one place to change.

The consistency problem. Handlers written individually drift. One uses setName(e.target.value), another uses setEmail(e.target.value.trim()), a third forgets to update on change. A generic handler enforces a consistent pattern. Every field updates the same way, and differences—like trimming or type coercion—are applied uniformly.

The scalability problem. A form with three fields is manageable with separate state. A form with thirty fields is not. The single-handler pattern scales because the handler does not know or care how many fields exist. It reads the name from the event and updates the corresponding property.

The dynamic field problem. When fields are generated from an array or added at runtime, separate state variables do not work—you cannot declare a useState for a field that does not exist yet. A state object handles dynamic fields naturally: each field is a key in the object, and the handler updates the key that the event names.

The readability problem. A single handler is not automatically more readable. If the state object is deeply nested or the handler contains complex conditionals, it can be harder to follow than separate state. The pattern is readable when the state is flat, the field names match the input names, and the handler is short.


a. The basic pattern: flat state object

The state is a single object. The keys are the field names. The values are the current values.

import { useState } from 'react';

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

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  };

  return (
    <form>
      <input name="name" value={form.name} onChange={handleChange} />
      <input name="email" value={form.email} onChange={handleChange} />
      <input name="password" type="password" value={form.password} onChange={handleChange} />
    </form>
  );
}

Each input has a name attribute that matches a key in the state object. The value is read from the corresponding property. The onChange handler is shared.

The handler destructures name and value from the event. It updates the state by spreading the previous object and overriding the property whose name matches the input’s name. The computed property name [name] is the key to the pattern: it lets the handler update a dynamic property without a switch or an if chain.

The functional update form setForm((prev) => ({ ...prev, [name]: value })) is important. It ensures the update is based on the latest state, which matters when multiple updates are batched. It also avoids stale closures when the handler is memoized with useCallback.


b. Handling checkboxes and other input types

Checkboxes report their state through e.target.checked, not e.target.value. The handler must check the input’s type and read the appropriate property.

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

For a checkbox, type is 'checkbox', and the handler stores the boolean checked. For other inputs, it stores the string value. This single handler covers both cases.

Radio buttons report their value through e.target.value, but only the selected radio in a group triggers a change. The handler is the same as for text inputs:

<input
  type="radio"
  name="plan"
  value="basic"
  checked={form.plan === 'basic'}
  onChange={handleChange}
/>
<input
  type="radio"
  name="plan"
  value="pro"
  checked={form.plan === 'pro'}
  onChange={handleChange}
/>

The radios share a name, which groups them. Each has a distinct value. The checked prop compares the current state to the radio’s value. The handler stores the selected value.

Number inputs report a string value even when type="number". To store a number, convert in the handler:

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

An empty number input produces Number(''), which is 0. This is often not the desired behavior. To store an empty string for an empty number input, check for the empty case:

[name]: type === 'number' && value !== '' ? Number(value) : value

Textareas and selects report their value through e.target.value, so the basic handler covers them without modification. Multi-selects report an array through selectedOptions, which requires a different handler.


c. Nested state, dynamic fields, and scaling

When the form data is nested—an address with a street, city, and zip, or a user with a profile—the flat handler does not work directly. The state is an object of objects, and the handler must update the correct nested property.

The simplest approach is to flatten the names. Instead of nesting, use names like address.street, address.city, and address.zip, and store them as a flat object:

const [form, setForm] = useState({
  name: '',
  'address.street': '',
  'address.city': '',
  'address.zip': ''
});

The handler is unchanged. The form data is flat, with dotted keys. When the data is submitted, the dotted keys can be reorganized into a nested structure. This approach keeps the handler simple at the cost of a slightly unusual data shape.

The alternative is a handler that navigates the nested structure:

const handleNestedChange = (e) => {
  const { name, value } = e.target;
  const [parent, child] = name.split('.');
  setForm((prev) => ({
    ...prev,
    [parent]: {
      ...prev[parent],
      [child]: value
    }
  }));
};

This handler assumes exactly two levels of nesting and uses a dotted name like address.street. For deeper nesting, the handler becomes more complex, and the flat-key approach is usually easier.

For dynamic fields—a list of items where each item has fields—the state is an array, and the handler must update the correct item by index or ID:

const [items, setItems] = useState([{ id: 1, name: '', quantity: 0 }]);

const handleItemChange = (index, field) => (e) => {
  const { value } = e.target;
  setItems((prev) =>
    prev.map((item, i) =>
      i === index ? { ...item, [field]: value } : item
    )
  );
};

Each item has its own handler, created by a function that takes the index and field. The handler updates only the item at that index and only the field that changed. This is the pattern for editable lists.

For a very large form, the single state object can become unwieldy. A reducer is often clearer:

function formReducer(state, action) {
  switch (action.type) {
    case 'change':
      return { ...state, [action.name]: action.value };
    case 'reset':
      return initialState;
    default:
      return state;
  }
}

const [form, dispatch] = useReducer(formReducer, initialState);

const handleChange = (e) => {
  const { name, value, type, checked } = e.target;
  dispatch({
    type: 'change',
    name,
    value: type === 'checkbox' ? checked : value
  });
};

The reducer separates the update logic from the event handling and makes the actions explicit. For forms with many fields and complex validation, the reducer pattern is often the better choice.


Complete Example Session

// ============================================
// PART 1: BASIC SINGLE HANDLER
// ============================================
import { useState } from 'react';

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

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  };

  return (
    <form>
      <input name="name" value={form.name} onChange={handleChange} />
      <input name="email" value={form.email} onChange={handleChange} />
      <input name="password" type="password" value={form.password} onChange={handleChange} />
    </form>
  );
}
// ============================================
// PART 2: HANDLER WITH CHECKBOX
// ============================================
const handleChange = (e) => {
  const { name, value, type, checked } = e.target;
  setForm((prev) => ({
    ...prev,
    [name]: type === 'checkbox' ? checked : value
  }));
};
// ============================================
// PART 3: HANDLER WITH NUMBER CONVERSION
// ============================================
const handleChange = (e) => {
  const { name, value, type } = e.target;
  setForm((prev) => ({
    ...prev,
    [name]: type === 'number' && value !== '' ? Number(value) : value
  }));
};
// ============================================
// PART 4: FORM WITH MIXED FIELD TYPES
// ============================================
function ProfileForm() {
  const [form, setForm] = useState({
    name: '',
    age: 0,
    subscribe: false,
    plan: 'basic',
    bio: ''
  });

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

  return (
    <form>
      <input name="name" value={form.name} onChange={handleChange} />
      <input name="age" type="number" value={form.age} onChange={handleChange} />
      <input name="subscribe" type="checkbox" checked={form.subscribe} onChange={handleChange} />
      <input name="plan" type="radio" value="basic" checked={form.plan === 'basic'} onChange={handleChange} />
      <input name="plan" type="radio" value="pro" checked={form.plan === 'pro'} onChange={handleChange} />
      <textarea name="bio" value={form.bio} onChange={handleChange} />
    </form>
  );
}
// ============================================
// PART 5: SELECT WITH SINGLE HANDLER
// ============================================
<select name="country" value={form.country} onChange={handleChange}>
  <option value="" disabled>Select</option>
  <option value="us">United States</option>
  <option value="ca">Canada</option>
</select>
// ============================================
// PART 6: FLAT NESTED NAMES
// ============================================
const [form, setForm] = useState({
  'address.street': '',
  'address.city': '',
  'address.zip': ''
});

// Input names use the dotted form
<input name="address.street" value={form['address.street']} onChange={handleChange} />
// ============================================
// PART 7: NESTED HANDLER
// ============================================
const handleNestedChange = (e) => {
  const { name, value } = e.target;
  const [parent, child] = name.split('.');
  setForm((prev) => ({
    ...prev,
    [parent]: { ...prev[parent], [child]: value }
  }));
};
// ============================================
// PART 8: DYNAMIC LIST OF FIELDS
// ============================================
function ItemList() {
  const [items, setItems] = useState([{ id: 1, name: '', quantity: 0 }]);

  const handleItemChange = (index, field) => (e) => {
    const value = field === 'quantity' ? Number(e.target.value) : e.target.value;
    setItems((prev) =>
      prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))
    );
  };

  return (
    <div>
      {items.map((item, index) => (
        <div key={item.id}>
          <input value={item.name} onChange={handleItemChange(index, 'name')} />
          <input type="number" value={item.quantity} onChange={handleItemChange(index, 'quantity')} />
        </div>
      ))}
    </div>
  );
}
// ============================================
// PART 9: RESET HANDLER
// ============================================
const resetForm = () => {
  setForm({ name: '', email: '', password: '' });
};
// ============================================
// PART 10: REDUCER PATTERN
// ============================================
function formReducer(state, action) {
  switch (action.type) {
    case 'change':
      return { ...state, [action.name]: action.value };
    case 'reset':
      return action.initial;
    default:
      return state;
  }
}

const [form, dispatch] = useReducer(formReducer, { name: '', email: '' });

const handleChange = (e) => {
  const { name, value, type, checked } = e.target;
  dispatch({
    type: 'change',
    name,
    value: type === 'checkbox' ? checked : value
  });
};

The ten parts covered the basic single handler, checkbox handling, number conversion, mixed field types, select with single handler, flat nested names, nested handler, dynamic list of fields, reset handler, and the reducer pattern.


Quick Reference

Handler Pattern

StepCode
Read eventconst { name, value, type, checked } = e.target
Choose valuetype === 'checkbox' ? checked : value
Update statesetForm((prev) => ({ ...prev, [name]: value }))

Field Types

TypePropertyHandler
textvalue[name]: value
emailvalue[name]: value
passwordvalue[name]: value
numbervalue[name]: Number(value)
checkboxchecked[name]: checked
radiovalue[name]: value
textareavalue[name]: value
selectvalue[name]: value
multi-selectselectedOptionscustom handler

State Structure

StructureUse Case
Flat objectMost forms
Dotted keysNested data, simple handler
Nested objectNested data, custom handler
ArrayDynamic list of fields
ReducerLarge forms, complex logic

Common Operations

OperationCode
Update field[name]: value
Toggle checkbox[name]: checked
Convert to numberNumber(value)
Reset formsetForm(initialState)
Update nested[parent]: { ...prev[parent], [child]: value }

Best Practices

✅ Do This:

// Use name attributes that match state keys
<input name="email" value={form.email} onChange={handleChange} />   // ✅

// Use functional updates
setForm((prev) => ({ ...prev, [name]: value }));                    // ✅

// Handle checkboxes with checked
const newValue = type === 'checkbox' ? checked : value;              // ✅

// Convert numbers explicitly
const newValue = type === 'number' ? Number(value) : value;          // ✅

// Use a single handler for all text-like fields
const handleChange = (e) => { /* ... */ };                           // ✅

// Reset by spreading the initial state
setForm(initialState);                                               // ✅

// Use a reducer for large forms
const [form, dispatch] = useReducer(reducer, initial);               // ✅

❌ Don’t Do This:

// Don't use separate state for every field in a large form
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// ... 20 more                                                    // ⚠️

// Don't forget the name attribute
<input value={form.email} onChange={handleChange} />                // ❌

// Don't mutate the state object
form.email = value;                                                 // ❌

// Don't read e.target.value for checkboxes
setForm({ ...form, [name]: value });                                // ❌ for checkbox

// Don't use the non-functional update in memoized handlers
setForm({ ...form, [name]: value });                                // ⚠️ stale risk

// Don't forget to handle empty number inputs
Number('') === 0;                                                    // ⚠️ often wrong

Common Pitfalls

PitfallWhy It HappensFix
Field not updatingNo name attributeAdd name matching state key
Checkbox stores stringRead value, not checkedUse type === 'checkbox' ? checked : value
Number stores stringNo conversionConvert with Number()
State staleNon-functional updateUse (prev) => ({ ...prev, ... })
Empty number becomes 0Number('') === 0Check for empty string
Nested update failsFlat handler on nested stateUse nested handler or flat keys

Real-World Examples

1. Login Form

const [form, setForm] = useState({ email: '', password: '' });

2. Signup Form

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

3. Profile Form

const [form, setForm] = useState({ name: '', bio: '', avatar: '', newsletter: false });

4. Checkout Form

const [form, setForm] = useState({
  'shipping.street': '',
  'shipping.city': '',
  'billing.same': true
});

5. Settings Form

const [form, setForm] = useState({ theme: 'light', notifications: true, language: 'en' });

6. Search Filters

const [filters, setFilters] = useState({ query: '', category: '', minPrice: 0 });

7. Dynamic Item List

const [items, setItems] = useState([{ id: 1, name: '', quantity: 0 }]);

8. Reset Button

<button type="button" onClick={() => setForm(initialState)}>Reset</button>

9. Submit Handler

const handleSubmit = (e) => {
  e.preventDefault();
  console.log(form);
};

10. Reducer for Large Form

const [form, dispatch] = useReducer(formReducer, initialState);

Visual

Single Handler Flow

┌─────────────────────────────────────────────────────────────┐
│  SINGLE HANDLER FLOW                                        │
│                                                             │
│  State:                                                     │
│  { name: '', email: '', password: '' }                      │
│                                                             │
│  User types in email field:                                 │
│    │                                                        │
│    ▼                                                        │
│  onChange fires with e.target:                              │
│    name = 'email'                                           │
│    value = 'a@b.com'                                        │
│    │                                                        │
│    ▼                                                        │
│  handleChange:                                              │
│    const { name, value } = e.target;                        │
│    setForm(prev => ({ ...prev, [name]: value }));           │
│    │                                                        │
│    ▼                                                        │
│  State updates:                                             │
│  { name: '', email: 'a@b.com', password: '' }               │
│                                                             │
│  One handler, all fields.                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Checkbox Handling

┌─────────────────────────────────────────────────────────────┐
│  TEXT INPUT                                                 │
│                                                             │
│  e.target.type    = 'text'                                  │
│  e.target.value   = 'hello'                                 │
│  e.target.checked = undefined                               │
│                                                             │
│  Handler stores: value                                      │
│  State: { field: 'hello' }                                  │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  CHECKBOX                                                   │
│                                                             │
│  e.target.type    = 'checkbox'                              │
│  e.target.value   = 'on'                                    │
│  e.target.checked = true                                    │
│                                                             │
│  Handler stores: checked                                    │
│  State: { field: true }                                     │
│                                                             │
│  The type determines which property is used.                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Flat vs Nested State

┌─────────────────────────────────────────────────────────────┐
│  FLAT WITH DOTTED KEYS                                      │
│                                                             │
│  {                                                          │
│    'address.street': '123 Main',                            │
│    'address.city': 'Springfield'                            │
│  }                                                          │
│                                                             │
│  Handler: [name]: value                                     │
│  Simple. Names include the path.                            │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  NESTED                                                     │
│                                                             │
│  {                                                          │
│    address: {                                               │
│      street: '123 Main',                                    │
│      city: 'Springfield'                                    │
│    }                                                        │
│  }                                                          │
│                                                             │
│  Handler: [parent]: { ...prev[parent], [child]: value }     │
│  More complex. Matches the data shape.                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Dynamic List Handler

┌─────────────────────────────────────────────────────────────┐
│  LIST OF ITEMS                                              │
│                                                             │
│  items = [                                                  │
│    { id: 1, name: 'Apple', quantity: 2 },                   │
│    { id: 2, name: 'Banana', quantity: 3 }                   │
│  ]                                                          │
│                                                             │
│  handleItemChange(index, field) => (e) => {                 │
│    setItems(prev =>                                         │
│      prev.map((item, i) =>                                  │
│        i === index ? { ...item, [field]: e.target.value }   │
│          : item                                             │
│      )                                                      │
│    );                                                       │
│  };                                                         │
│                                                             │
│  Each input binds to the handler for its index and field.   │
│  Only the targeted item updates.                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
StateSingle object with field names as keys
HandlerOne function, reads name and value from event
Computed property[name]: value
Functional updatesetForm((prev) => ({ ...prev, [name]: value }))
CheckboxUse type === 'checkbox' ? checked : value
NumberConvert with Number(value)
Nested dataDotted keys or nested handler
Dynamic fieldsHandler with index and field parameters
Large formsuseReducer

Key takeaways:

  • A single state object and a single handler replace one useState and one handler per field. The handler reads the field name from e.target.name and updates the matching property in the state object using a computed property name.
  • The name attribute is the link between the input and the state. It must match a key in the state object. Without it, the handler cannot determine which field changed.
  • Checkboxes need special handling. They report their state through e.target.checked, not e.target.value. The handler checks the input’s type and reads the appropriate property.
  • Numbers require explicit conversion. e.target.value is always a string, even for type="number". The handler converts it with Number() when the type is 'number', and handles the empty-string case to avoid storing 0 for an empty field.
  • The functional update prevents stale state. setForm((prev) => ({ ...prev, [name]: value })) uses the latest state, which matters when updates are batched or the handler is memoized.
  • Nested data can be handled with dotted keys or a nested handler. Dotted keys keep the handler simple at the cost of an unusual data shape. A nested handler preserves the data shape but is more complex.
  • Dynamic fields use a parameterized handler. Each field gets a handler created by a function that captures its index and field name. The handler updates only that item and that field.
  • Large forms are often clearer with a reducer. useReducer separates the update logic from the event handling and makes the actions explicit. It is the better choice when the form has many fields or complex validation.

Remember: The single-handler pattern is the standard way to manage forms in React. It reduces repetition, enforces consistency, and scales from three fields to thirty. The key is the name attribute: it links each input to a property in the state object, and the handler uses it to route the update. Checkboxes and numbers need small adjustments to the handler. Nested data and dynamic fields need larger ones. And when the form grows beyond what a single handler can comfortably manage, a reducer takes over. The pattern is worth learning well, because forms are everywhere, and this is how they are built.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!