The core mechanism
React’s data binding philosophy can be summed up as: the UI is derived from state. When state changes, React re-renders and updates the DOM to match.
For form fields, this idea is expressed with controlled elements. By default, React renders an <input> and leaves it alone — an uncontrolled element. But if you pass a value attribute, you've opted in to React managing that input. The field becomes locked to whatever value you supply:
import React from 'react';
function App() {
return (
<input value="Hello World" />
);
}
export default App;
If you try to edit that input, nothing happens. React holds it at the specified string. That's one-way data binding: state flows into the field, but edits don't flow back out.
To close the loop, attach an onChange handler. When the user edits the field, the event fires and you can read event.target.value — the user’s attempted input — and write it into state. React re-renders, and the input displays the new state value. That's two-way data binding:
import React from 'react';
function App() {
const [state, setState] = React.useState(
'Hello World'
);
return (
<>
<input
value={state}
onChange={(event) => {
setState(event.target.value);
}}
/>
<p>
<strong>Current value:</strong>
{state}
</p>
</>
);
}
export default App;
Every form control type works on this same foundation, but the details differ. Text inputs use value; radio buttons and checkboxes use checked. Textareas behave like text inputs. Let's go through each type.
Text inputs and textareas
Text inputs bind with value + onChange:
import React from 'react';
function App() {
const [name, setName] = React.useState('');
return (
<>
<form>
<label htmlFor="name-field">
Name:
</label>
<input
id="name-field"
value={name}
onChange={event => {
setName(event.target.value);
}}
/>
</form>
<p>
<strong>Current value:</strong>
{name || '(empty)'}
</p>
</>
);
}
export default App;
The value attribute forces the displayed text to match your state variable. The onChange handler updates that state when the user types. Use an empty string as the initial state, not undefined or null, to keep the input fully controlled:
// 🚫 Incorrect:
const [name, setName] = React.useState();
// ✅ Correct:
const [name, setName] = React.useState('');
Variants like password and email inputs work identically — the only difference is the type attribute. The <textarea> element also follows the same pattern, so you use value and onChange exactly as you would for a text input:
import React from 'react';
function App() {
const [comment, setComment] =
React.useState('');
return (
<>
<form>
<label htmlFor="comment-field">
Share your experiences:
</label>
<textarea
id="comment-field"
value={comment}
onChange={(event) => {
setComment(event.target.value);
}}
/>
</form>
<p>
<strong>Current value:</strong>
{comment || '(empty)'}
</p>
</>
);
}
export default App;
For textareas, an empty string initial state avoids the same controlled/uncontrolled pitfall:
// 🚫 Incorrect:
const [comment, setComment] = React.useState();
// ✅ Correct:
const [comment, setComment] = React.useState('');
Radio buttons
Radio buttons introduce a 1:many relationship: multiple inputs all bound to a single piece of state. That state doesn't store what's typed — it stores which option is selected. Each radio button has a value, and the state variable holds whichever value corresponds to the ticked button.
Two attributes matter:
checked— a boolean expression telling React whether this particular button is the one selected. This is how you make a radio button controlled.onChange— fires when the user ticks this button. Copyevent.target.valueinto state to select it.
A full radio button setup therefore involves several attributes per input. Here's a complete example:
import React from 'react';
function App() {
const [hasAgreed, setHasAgreed] =
React.useState();
return (
<>
<form>
<fieldset>
<legend>Do you agree?</legend>
<input
type="radio"
name="agreed-to-terms"
id="agree-yes"
value="yes"
checked={hasAgreed === 'yes'}
onChange={(event) => {
setHasAgreed(
event.target.value
);
}}
/>
<label htmlFor="agree-yes">
Yes
</label>
<br />
<input
type="radio"
name="agreed-to-terms"
id="agree-no"
value="no"
checked={hasAgreed === 'no'}
onChange={(event) => {
setHasAgreed(
event.target.value
);
}}
/>
<label htmlFor="agree-no">
No
</label>
</fieldset>
</form>
<p>
<strong>Has agreed:</strong>
{hasAgreed || 'undefined'}
</p>
</>
);
}
export default App;
Each attribute serves a distinct purpose:
| Attribute | Type | Explanation |
|---|---|---|
| id | string | A globally-unique identifier for this radio button, used to improve accessibility and usability. |
| name | string | Groups a set of radio buttons together, so that only one can be selected at a time. Must be the same value for all radio buttons in the group. |
| value | string | Specifies the “thing” that this radio button represents. This is what will be captured/stored if this particular option is selected. |
| checked | boolean | Controls whether the radio button is checked or not. By passing a boolean value, React will make this a “controlled” input. |
| onChange | function | Like other form controls, this function will be invoked when the user changes the selected option. We use this function to update our state. |
A table like this is hard to scale by hand, so it's often cleaner to iterate over your options rather than writing each <input> element out. This becomes necessary when the options themselves are dynamic — for example, fetched from an API:
import React from 'react';
function App() {
const [language, setLanguage] =
React.useState('english');
return (
<form>
<fieldset>
<legend>Select language:</legend>
{VALID_LANGUAGES.map((option) => (
<div key={option}>
<input
type="radio"
name="current-language"
id={option}
value={option}
checked={option === language}
onChange={(event) => {
setLanguage(
event.target.value
);
}}
/>
<label htmlFor={option}>
{option}
</label>
</div>
))}
</fieldset>
<p>
<strong>Selected language:</strong>
{language || 'undefined'}
</p>
</form>
);
}
const VALID_LANGUAGES = [
'mandarin',
'spanish',
'english',
'hindi',
'arabic',
'portugese',
];
export default App;
One gotcha when iterating: don't name the map parameter the same as your state variable. It shadows the outer variable, leaving your state inaccessible inside the callback — which breaks the checked attribute entirely. Use a generic name like option to avoid that trap.
Checkboxes
A single checkbox works like a radio button with binary state. Use checked to control it and onChange to update state:
import React from 'react';
function App() {
const [optIn, setOptIn] =
React.useState(false);
return (
<>
<form>
<input
type="checkbox"
id="opt-in-checkbox"
checked={optIn}
onChange={(event) => {
setOptIn(event.target.checked);
}}
/>
<label htmlFor="opt-in-checkbox">
I agree to the terms
</label>
</form>
<p>
<strong>Opt in:</strong>{' '}
{optIn.toString()}
</p>
</>
);
}
export default App;
Groups of checkboxes are trickier, because unlike radio buttons, the user can tick more than one. A single string can't represent the selection. Instead, keep an object mapping each option name to a boolean — true if ticked, false otherwise:
const initialToppings = {
anchovies: false,
chicken: false,
tomatoes: false,
}
Then iterate over the object's keys in JSX, rendering a checkbox for each. The checked attribute reads the boolean from the state object; the onChange handler flips it. Since React state must be immutable, create a new object with the spread operator, invert the relevant key, and put that in state:
import React from 'react';
const initialToppings = {
anchovies: false,
chicken: false,
tomatoes: false,
};
function App() {
const [pizzaToppings, setPizzaToppings] =
React.useState(initialToppings);
// Get a list of all toppings.
// ['anchovies', 'chicken', 'tomato'];
const toppingsList = Object.keys(
initialToppings
);
return (
<>
<form>
<fieldset>
<legend>Select toppings:</legend>
{/*
Iterate over those toppings, and
create a checkbox for each one:
*/}
{toppingsList.map((option) => (
<div key={option}>
<input
type="checkbox"
id={option}
value={option}
checked={
pizzaToppings[option] ===
true
}
onChange={(event) => {
setPizzaToppings({
...pizzaToppings,
[option]:
event.target.checked,
});
}}
/>
<label htmlFor={option}>
{option}
</label>
</div>
))}
</fieldset>
</form>
<p>
<strong>Stored state:</strong>
</p>
<p className="output">
{JSON.stringify(
pizzaToppings,
null,
2
)}
</p>
</>
);
}
export default App;
The table below lays out each attribute's job in this pattern:
| Attribute | Type | Explanation |
|---|---|---|
| id | string | A globally-unique identifier for this checkbox, used to improve accessibility and usability. |
| value | string | Specifies the “thing” that we're ticking off and on with this checkbox. |
| checked | boolean | Controls whether the checkbox is checked or not. |
| onChange | function | Like other form controls, this function will be invoked when the user ticks or unticks the checkbox. We use this function to update our state. |
A name attribute isn't strictly required here because you're controlling each checkbox individually, but it's harmless to include.
Selects: Radio Buttons with a Dropdown
The <select> tag serves the same purpose as radio buttons — picking one value from a set — but is better suited for long lists. While it technically supports multi-selection, that mode is rarely used today.
React treats selects much like text inputs. Instead of manually toggling a selected attribute on the correct <option> child, you bind the whole control using the same value and onChange pair you are already familiar with:
import React from 'react';
function App() {
const [age, setAge] =
React.useState('0-18');
return (
<>
<form>
<label htmlFor="age-select">
How old are you?
</label>
<select
id="age-select"
value={age}
onChange={(event) => {
setAge(event.target.value);
}}
>
<option value="0-18">
18 and under
</option>
<option value="19-39">
19 to 39
</option>
<option value="40-64">
40 to 64
</option>
<option value="65-infinity">
65 and over
</option>
</select>
</form>
<p>
<strong>Selected value:</strong>
{age}
</p>
</>
);
}
export default App;
This is a significant departure from vanilla JavaScript, where you'd have to query the DOM and update the selected property on the matching option. React smooths over that rough edge, so the callback looks identical to a text input's. You still have to write out the <option> elements yourself, along with the string values that should land in state when the user makes a choice.
The main trap here is the same one you face with text inputs: the initial state must match an existing option. A typo in either the state declaration or in an option's value can lead to confusing behavior where nothing appears selected:
// This initial value:
const [age, setAge] = React.useState("0-18");
// Must match one of the options:
<select>
<option
value="0-18"
>
18 and under
</option>
</select>
A cleaner approach is to stop writing option tags by hand and instead derive them from the same array that seeds your state. This gives you a single source of truth and eliminates the possibility of mismatched values:
import React from 'react';
// The source of truth!
const OPTIONS = [
{
label: '18 and under',
value: '0-18',
},
{
label: '19 to 39',
value: '19-39',
},
{
label: '40 to 64',
value: '40-64',
},
{
label: '65 and over',
value: '65-infinity',
},
];
function App() {
// Grab the first option from the array.
// Set its value into state:
const [age, setAge] = React.useState(
OPTIONS[0].value
);
return (
<>
<form>
<label htmlFor="age-select">
How old are you?
</label>
<select
id="age-select"
value={age}
onChange={(event) => {
setAge(event.target.value);
}}
>
{/*
Iterate over that array, to create
the <option> tags dynamically:
*/}
{OPTIONS.map((option) => (
<option
key={option.value}
value={option.value}
>
{option.label}
</option>
))}
</select>
</form>
<p>
<strong>Selected value:</strong>
{age}
</p>
</>
);
}
export default App;
Beyond Basic Text
According to MDN, the <input> tag accepts 22 distinct values for the type attribute (opens in new tab). Several of these produce interfaces beyond the simple text box, including range sliders, date pickers, and color swatches.
Yet structurally, these are all text inputs in disguise. Whether you are working with type="range" or type="color", React's contract stays the same: lock the field's current value to value from state, and use onChange to sync state whenever the user interacts. Here is a slider:
import React from 'react';
function App() {
const [volume, setVolume] =
React.useState(50);
return (
<>
<form>
<label htmlFor="volume-slider">
Audio volume:
</label>
<input
type="range"
id="volume-slider"
min={0}
max={100}
value={volume}
onChange={(event) => {
setVolume(event.target.value);
}}
/>
</form>
<p>
<strong>Current value:</strong>
{volume}
</p>
</>
);
}
export default App;
This color picker follows the exact same pattern:
import React from 'react';
function App() {
const [color, setColor] =
React.useState('#FF0000');
return (
<>
<form>
<label htmlFor="color-picker">
Select a color:
</label>
<input
type="color"
id="color-picker"
value={color}
onChange={(event) => {
setColor(event.target.value);
}}
/>
</form>
<p>
<strong>Current value:</strong>
{color}
</p>
</>
);
}
export default App;
Wiring Up Labels
Every form field in these examples carries an id attribute, which the corresponding <label> references via htmlFor (React's spelling of the for attribute). This connection is not optional decoration:
- Accessibility. Screen readers and other assistive technologies use the label to announce what each control is for. Without it, users relying on narration — including those who are blind or have cognitive disabilities — lack crucial context.
- Usability. Clicking a properly linked label focuses its control. This matters a great deal for small targets like checkboxes and radio buttons.
Correct wiring requires the id to be unique across the entire document. However, React heavily encourages reusability, so you will frequently render the same form component multiple times on one page. Hardcoding an id would break the global uniqueness rule.
React addresses this with the useId hook, which produces a unique identifier for each render of a component:
import React from 'react';
function LoginForm() {
const [username, setUsername] =
React.useState('');
const [password, setPassword] =
React.useState('');
const id = React.useId();
const usernameId = `${id}-username`;
const passwordId = `${id}-password`;
return (
<>
<form>
<div>
<label htmlFor={usernameId}>
Username:
</label>
<input
id={usernameId}
value={username}
onChange={(event) => {
setUsername(
event.target.value
);
}}
/>
</div>
<div>
<label htmlFor={passwordId}>
Password:
</label>
<input
id={passwordId}
type="password"
value={password}
onChange={(event) => {
setPassword(
event.target.value
);
}}
/>
</div>
<button>Login</button>
</form>
</>
);
}
export default LoginForm;
Every time LoginForm is mounted, React guarantees it gets a fresh, collision-free ID. More details on this hook are available in the official React documentation (opens in new tab).



