Skip to main content

Form Input Typing: Controlled Components TypeScript

A controlled component is a React component whose form values are managed by component state. When the user types, React's state updates, and React re-renders the component with the new value. In TypeScript, controlled components require three pieces of typing: the state type (usually string for text inputs), the change handler type (React.ChangeEvent<HTMLInputElement>), and optionally the element's type attributes. This ensures that your form data flows correctly and matches your expectations at every step.

How Do You Create a Controlled Input in TypeScript?

A controlled input has three parts: state holding the value, an onChange handler updating the state, and a value prop that reflects the state. In TypeScript, you declare the state as string, type the handler parameter as React.ChangeEvent<HTMLInputElement>, and extract the value from event.currentTarget.value. This ensures the value is always a string and the handler correctly updates the state.

import { useState } from 'react';

export const ControlledInput = () => {
const [email, setEmail] = useState<string>('');

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// currentTarget.value is guaranteed to be a string
setEmail(event.currentTarget.value);
};

return (
<div>
<input
type="email"
value={email}
onChange={handleChange}
placeholder="Enter your email"
/>
<p>Email: {email}</p>
</div>
);
};

The value={email} prop makes the input controlled; React is the source of truth. When you type, onChange fires, setEmail updates state, and React re-renders with the new value. Without the value prop, the input is uncontrolled, and React doesn't manage its value.

Typing Multiple Form Fields

When a form has multiple inputs, you often store all values in a single object. TypeScript helps you ensure every field is handled correctly.

import { useState } from 'react';

// Define the form data type
interface FormData {
firstName: string;
lastName: string;
email: string;
}

export const ControlledForm = () => {
const [formData, setFormData] = useState<FormData>({
firstName: '',
lastName: '',
email: '',
});

// Create a generic handler that updates any field
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.currentTarget;
setFormData(prevData => ({
...prevData,
[name]: value,
}));
};

return (
<form>
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="First name"
/>
<input
type="text"
name="lastName"
value={formData.lastName}
onChange={handleChange}
placeholder="Last name"
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
<p>Name: {formData.firstName} {formData.lastName}</p>
</form>
);
};

This pattern is type-safe because FormData defines which fields exist. If you accidentally reference formData.phone and it's not in the interface, TypeScript warns you immediately.

Typing Textareas and Select Elements

Textareas and select elements are controlled the same way as inputs, but they have different HTML types. A textarea is React.ChangeEvent<HTMLTextAreaElement>, and a select is React.ChangeEvent<HTMLSelectElement>.

import { useState } from 'react';

export const ControlledFormElements = () => {
const [message, setMessage] = useState<string>('');
const [country, setCountry] = useState<string>('us');

const handleMessageChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(event.currentTarget.value);
};

const handleCountryChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setCountry(event.currentTarget.value);
};

return (
<div>
<textarea
value={message}
onChange={handleMessageChange}
placeholder="Type a message"
/>
<select value={country} onChange={handleCountryChange}>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
</select>
<p>Message: {message}</p>
<p>Country: {country}</p>
</div>
);
};

Notice that the value state is string for both textarea and select. Even though a select contains multiple options, the selected value is always a string (or number if you parse it).

Typing Checkbox and Radio Inputs

Checkboxes use checked instead of value, and the state is boolean. Radio buttons are trickier: they share the same name attribute, and the onChange fires on the selected one.

import { useState } from 'react';

export const CheckboxesAndRadios = () => {
const [agreed, setAgreed] = useState<boolean>(false);
const [preference, setPreference] = useState<'email' | 'sms'>('email');

const handleAgreedChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setAgreed(event.currentTarget.checked);
};

const handlePreferenceChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPreference(event.currentTarget.value as 'email' | 'sms');
};

return (
<div>
<label>
<input
type="checkbox"
checked={agreed}
onChange={handleAgreedChange}
/>
I agree to the terms
</label>

<fieldset>
<label>
<input
type="radio"
name="preference"
value="email"
checked={preference === 'email'}
onChange={handlePreferenceChange}
/>
Email
</label>
<label>
<input
type="radio"
name="preference"
value="sms"
checked={preference === 'sms'}
onChange={handlePreferenceChange}
/>
SMS
</label>
</fieldset>

<p>Agreed: {agreed ? 'Yes' : 'No'}</p>
<p>Preference: {preference}</p>
</div>
);
};

For radio buttons, the type annotation as 'email' | 'sms' tells TypeScript to narrow the string value to your specific options. This is safer than a plain string because you can't accidentally assign an invalid value.

Key Takeaways

  • Controlled components have three parts: state, a handler, and a value or checked prop that reflects state.
  • Type input change handlers as React.ChangeEvent<HTMLInputElement> and extract the value with event.currentTarget.value.
  • Use an interface to define all form fields together; this ensures you handle every field and prevents typos.
  • Checkboxes use checked (boolean), while text inputs, textareas, and selects use value (string or number).
  • TypeScript prevents you from referencing fields that don't exist in your form data, catching mistakes early.

Frequently Asked Questions

Should I use event.target or event.currentTarget in form handlers?

Always use event.currentTarget. It's typed to match the HTML element you specified in the generic parameter, so TypeScript ensures you're accessing valid properties. target could theoretically be a different element due to event delegation.

How do I handle a form with hundreds of fields?

For large forms, group related fields into nested objects within your form state interface. Alternatively, use a library like react-hook-form or Formik, which is designed for complex forms. For basic forms with many fields, a flat object with a dynamic handler (as shown in the form example) works fine.

Can I use controlled components for file inputs?

No, file inputs cannot be controlled in React because the browser doesn't allow setting value for security reasons. Use an uncontrolled input with a ref and read files from ref.current.files.

How do I reset a form to its initial state?

Create a reset function that calls the state setter with the initial values. You can then call this function in a button's onClick handler or after form submission.

What's the performance impact of controlled components?

Controlled components re-render on every keystroke because state updates. For large component trees, use React.memo() on child components to prevent unnecessary re-renders. For most applications, the performance impact is negligible.

Further Reading