The bookkeeper opened a new invoice, and the form picked Northwind Traders with its usual 30 days to pay. The invoice was for Brightline Studio, so she switched the customer. Brightline pays in 14 days. The terms field still said 30, nobody looked twice, and the invoice went out due on 15 December instead of 29 November. Brightline paid on the 15th, exactly as the invoice allowed.
The field had been written with defaultValue, which React reads once, when the input first appears. The quick fix swapped it for value, and then nobody could type in the field at all.
Both bugs answer the same question the wrong way: who owns the value? A controlled component shows the value its parent passes and reports each change for the parent to keep; an uncontrolled component keeps its own value, starting from a default. Well-made inputs offer both, and their props say which one is in use.
A default that is read once
This was the first terms field, and it looks harmless:
// Avoid: defaultValue is read once, when the input mounts
export function TermsField({ customer }: { customer: Customer }) {
return (
<label>
Payment terms (days)
<input
name="terms"
type="number"
defaultValue={customer.terms}
/>
</label>
);
}defaultValue is the starting text of an input that keeps its own text from then on, as react.dev’s reference for <input> describes. When the customer changes, the form renders again with 14, the prop changes, and the input ignores it. It already has text of its own.
The fix that followed made the opposite mistake. It changed one prop, to value={customer.terms}, and now the input shows the customer’s terms on every render while nothing ever changes them. Typing a 4 changes the DOM for a moment before React puts 14 back. React warns about exactly this in development: a value with no onChange makes a read-only field.
The two versions fail in opposite directions for one reason. The page needs the terms to follow the customer and to follow the typing, and only one component sees both events: the form. The form has to own the terms.
One owner for every value
A value on screen lives in exactly one place. Either the parent holds it in state, passes it down and hears about each change, or the component holds it and the parent only sets where it starts. react.dev calls these controlled and uncontrolled, and the names travel well beyond inputs: a dialog’s open state, a tab list’s selected tab and an accordion’s open sections all have an owner too.
Every value has one owner, and the props say which. value with onValueChange means the parent owns it. defaultValue means the component does. A component that accepts both lets each page choose, the way a native input does.
Building a days field that works both ways
The terms field becomes DaysField, a number input that also shows the due date the days give. It lives in days-field.tsx, shown here in three fragments.
Props that name the owner
interface DaysFieldProps {
label: string;
name: string;
/** The date the days count from, as YYYY-MM-DD. */
issued: string;
/** Controlled: the parent owns the text and passes it back. */
value?: string;
/** Uncontrolled: the text it starts with, then keeps itself. */
defaultValue?: string;
/** Every change, in either mode. */
onValueChange?: (value: string) => void;
}Three props carry the whole contract. value and defaultValue are the two ways in, and a caller passes one of them. onValueChange reports every change in both modes, so an uncontrolled caller can still listen without taking ownership.
Deciding who owns the text
// The parent's value wins whenever it is given. Otherwise the field
// keeps its own text, which starts from defaultValue.
const [own, setOwn] = useState(defaultValue);
const controlled = value !== undefined;
const text = controlled ? value : own;
function change(next: string) {
if (!controlled) setOwn(next);
onValueChange?.(next);
}The field keeps state either way, but only shows it when no value arrives. That is the whole trick: the text on screen is derived from the props on every render, never copied out of them. In controlled mode, change only reports, and the field waits for its parent to pass the new text back.
The field, and the date it gives
const due = dueDate(issued, text);
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input
id={id}
name={name}
type="number"
inputMode="numeric"
min={0}
max={120}
value={text}
onChange={(event) => change(event.target.value)}
aria-describedby={`${id}-due`}
/>
<p id={`${id}-due`}>
{due ? `Due on ${due}` : "Enter a number of days"}
</p>
</div>
);The inner input is always controlled by the field itself, whichever mode the caller chose. The due date sits under it and is tied to it with aria-describedby, so the date is read with the field.
The invoice form owns the terms
The new-invoice form passes the terms in and takes each change back:
// The new-invoice form. The page owns the customer and the terms,
// because a new customer brings new terms; the purchase order number
// stays in its input.
import { useId, useState } from "react";
import type { FormEvent } from "react";
import { CUSTOMERS, NORTHWIND } from "./customers";
import { DaysField } from "./days-field";
export interface InvoiceDraft {
customerId: string;
terms: number;
purchaseOrder: string;
}
interface NewInvoiceProps {
issued: string;
onCreate: (draft: InvoiceDraft) => void;
}
export function NewInvoice({ issued, onCreate }: NewInvoiceProps) {
const id = useId();
const [customer, setCustomer] = useState(NORTHWIND);
const [terms, setTerms] = useState(String(NORTHWIND.terms));
// One event sets both: no effect copies the customer's terms
// afterwards.
function chooseCustomer(customerId: string) {
const next =
CUSTOMERS.find((c) => c.id === customerId) ?? NORTHWIND;
setCustomer(next);
setTerms(String(next.terms));
}
function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const purchaseOrder = new FormData(event.currentTarget).get(
"purchaseOrder",
);
onCreate({
customerId: customer.id,
terms: Number(terms),
purchaseOrder: String(purchaseOrder),
});
}
return (
<form onSubmit={create}>
<div className="field">
<label htmlFor={`${id}-customer`}>Customer</label>
<select
id={`${id}-customer`}
value={customer.id}
onChange={(event) => chooseCustomer(event.target.value)}
>
{CUSTOMERS.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<DaysField
label="Payment terms (days)"
name="terms"
issued={issued}
value={terms}
onValueChange={setTerms}
/>
<div className="field">
<label htmlFor={`${id}-po`}>Purchase order number</label>
{/* A new customer, a new input: its text starts empty. */}
<input
key={customer.id}
id={`${id}-po`}
name="purchaseOrder"
autoComplete="off"
/>
</div>
<button type="submit">Create invoice</button>
</form>
);
}Choosing a customer sets both the customer and the terms in one event handler, so no effect has to notice the change and copy the terms afterwards. The purchase order number stays uncontrolled, since nothing reads it before the form is sent. Its key is the customer’s id, so a new customer brings a new input that starts empty, as resetting state with a key describes.
Here is the form from the opening, render by render:
| Moment | terms | Description |
|---|---|---|
| First render, Northwind | “30” | Due on 15 December 2026 |
| Brightline chosen | “14” | Due on 29 November 2026 |
| Field cleared | “” | Enter a number of days |
| 45 typed | “45” | Due on 30 December 2026 |
The terms are a string, as an input’s value always is. An empty field is a real state the person passes through on the way to a new number, and a number type would have nowhere to put it.
A settings page that lets the field keep its text
On a customer’s settings page, nothing needs the days until they are saved, so the same field runs uncontrolled:
// A customer's default terms on their settings page: the field keeps
// its own text, and the form reads it when it is saved.
import type { FormEvent } from "react";
import { DaysField } from "./days-field";
interface CustomerTermsProps {
terms: number;
onSave: (terms: string) => void;
}
export function CustomerTerms({ terms, onSave }: CustomerTermsProps) {
function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
onSave(String(new FormData(event.currentTarget).get("terms")));
}
return (
<form onSubmit={save}>
<DaysField
label="Default payment terms (days)"
name="terms"
issued="2026-11-15"
defaultValue={String(terms)}
/>
<button type="submit">Save terms</button>
</form>
);
}The page renders no state of its own. The field starts at the customer’s 30 days, keeps whatever is typed, and still shows the due date as it changes. On save, the form reads the text through FormData, by the input’s name.
Pitfall
A common repair for the opening’s bug keeps defaultValue and adds an effect that copies the customer’s terms into the field’s state when the customer changes. It works, at the price of a render that still shows the old terms and an effect whose only job is to undo the wrong owner. When a value has to follow something the parent knows, the parent owns it. When a component’s own state has to start over, give it a new key, or adjust the state while rendering.
What each owner costs
Neither mode is the better one. Each puts the value where some work is easy and other work is awkward.
Choosing the owner
| Question | Controlled | Uncontrolled |
|---|---|---|
| Who holds the value | The parent’s state | The component |
| Can the parent change it later | At any time | Only by remounting it |
| What re-renders on a keystroke | The parent and its children | The component alone |
| How the page reads it | From its own state | From the form on submit |
The second row decides most cases. If anything other than the person typing will ever change the value, such as another field, a server response or a “Use the customer’s terms” button, the parent has to own it.
Re-renders follow the owner
Each keystroke in the controlled terms field re-renders NewInvoice and everything it renders. In a form of four fields that is no cost to worry about. Control every field of a long form from one state object, though, and each key re-renders every field. Leave the values that nothing reads before submit uncontrolled, as the purchase order number is, and keep the re-render to the fields that need it.
The due date is read when the field is
The due date is the field’s description rather than a live region. A screen reader reads the label, the value and the date when focus arrives, and again whenever the person asks, without interrupting each digit typed. The input keeps its native number semantics, so it is announced as a spin button and the arrow keys step through the days.
Testing both modes through the page
The test drives each mode the way a person would and reads the field by its role and label, so the assertions hold whichever owner the page picked:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { CustomerTerms } from "./customer-terms";
import { NewInvoice } from "./new-invoice";
test("a new customer brings its terms and due date", async () => {
const user = userEvent.setup();
render(<NewInvoice issued="2026-11-15" onCreate={() => {}} />);
const terms = screen.getByRole("spinbutton", {
name: "Payment terms (days)",
});
expect(terms).toHaveValue(30);
expect(terms).toHaveAccessibleDescription(
"Due on 15 December 2026",
);
await user.selectOptions(
screen.getByRole("combobox", { name: "Customer" }),
"Brightline Studio",
);
expect(terms).toHaveValue(14);
expect(terms).toHaveAccessibleDescription(
"Due on 29 November 2026",
);
await user.clear(terms);
await user.type(terms, "45");
expect(terms).toHaveAccessibleDescription(
"Due on 30 December 2026",
);
});
test("each customer starts with an empty order number", async () => {
const user = userEvent.setup();
const onCreate = vi.fn();
render(<NewInvoice issued="2026-11-15" onCreate={onCreate} />);
const name = { name: "Purchase order number" };
await user.type(screen.getByRole("textbox", name), "PO-7731");
await user.selectOptions(
screen.getByRole("combobox", { name: "Customer" }),
"Brightline Studio",
);
expect(screen.getByRole("textbox", name)).toHaveValue("");
await user.type(screen.getByRole("textbox", name), "BL-204");
await user.click(
screen.getByRole("button", { name: "Create invoice" }),
);
expect(onCreate).toHaveBeenCalledWith({
customerId: "brightline",
terms: 14,
purchaseOrder: "BL-204",
});
});
test("uncontrolled, the form reads the field's text", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<CustomerTerms terms={30} onSave={onSave} />);
const terms = screen.getByRole("spinbutton", {
name: "Default payment terms (days)",
});
await user.clear(terms);
await user.type(terms, "21");
expect(terms).toHaveAccessibleDescription("Due on 6 December 2026");
await user.click(
screen.getByRole("button", { name: "Save terms" }),
);
expect(onSave).toHaveBeenCalledWith("21");
});All three pass. The first replays the opening: the terms follow Brightline, and the due date follows the typing. The third proves the uncontrolled field needs no state above it.
A Server Component can only configure the uncontrolled one
DaysField holds state, so its file needs "use client". A Server Component can still render it uncontrolled, with a defaultValue read straight from the database, because every prop it needs is data. Controlled is different: onValueChange is a function, and a Server Component can’t pass one to client code unless it is a Server Function. A controlled field always has a client component above it holding the state. Both modes put their starting value in the server’s HTML.
React 19 resets the fields it doesn’t own
Controlled and uncontrolled components work as they did in React 18. What React 19 adds is a reason to prefer uncontrolled fields in forms that submit through an Action: after the Action succeeds, React resets every uncontrolled field in the form, and requestFormReset does the same on request. The reset reaches native inputs only. DaysField keeps its text in state even when uncontrolled, so give it a key from the saved record to start it over, and a controlled form clears its own state by hand. The React Compiler memoises the handlers and the derived due date, and changes nothing about who owns what.
When a component needs only one mode
Offering both modes is work for components that many pages use, such as a design system’s inputs. A component written for one screen should take the mode that screen needs. If the parent owns the value, make value and onValueChange required and drop defaultValue, so a caller can’t forget half the pair.
And many fields need no component at all. A plain <input name> whose value is read on submit is already uncontrolled, with no state written for it. Reach for the controlled form when something on the page has to react before the form is sent.
How Turtlekit’s TextField and Select share one owner rule
TextField and Select take the same three props as most Turtlekit inputs: value with onValueChange, or defaultValue, with the label, hint and error wired for you. The new-invoice form needs no field of its own:
// The new-invoice form with Turtlekit. The page controls the customer
// and the terms; the purchase order number keeps its own text until
// the form is sent.
import { useState } from "react";
import type { FormEvent } from "react";
import { Button, Select, Stack, TextField } from "turtlekit";
import { CUSTOMERS, NORTHWIND, dueDate } from "./customers";
const OPTIONS = CUSTOMERS.map((c) => ({
value: c.id,
label: c.name,
}));
interface NewInvoiceProps {
issued: string;
onCreate: (form: FormData) => void;
}
export function NewInvoice({ issued, onCreate }: NewInvoiceProps) {
const [customer, setCustomer] = useState(NORTHWIND);
const [terms, setTerms] = useState(String(NORTHWIND.terms));
const due = dueDate(issued, terms);
function chooseCustomer(customerId: string) {
const next =
CUSTOMERS.find((c) => c.id === customerId) ?? NORTHWIND;
setCustomer(next);
setTerms(String(next.terms));
}
function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
onCreate(new FormData(event.currentTarget));
}
return (
<Stack as="form" gap="group" onSubmit={create}>
<Select
label="Customer"
name="customer"
options={OPTIONS}
value={customer.id}
onValueChange={chooseCustomer}
/>
<TextField
label="Payment terms"
name="terms"
type="number"
suffix="days"
width="short"
value={terms}
onValueChange={setTerms}
hint={due ? `Due on ${due}` : "Enter a number of days"}
/>
<TextField
key={customer.id}
label="Purchase order number"
name="purchaseOrder"
autoComplete="off"
/>
<Button type="submit">Create invoice</Button>
</Stack>
);
}The terms follow the customer, the due date is the field’s hint and is read with it, and “days” sits beside the number as a suffix. The purchase order number is uncontrolled and starts empty for each customer, through the same key.
Turtlekit exports the same rule as useControllableState, for components of your own. It replaces the owner fragment of DaysField with one call:
const [text, setText] = useControllableState({
value,
defaultValue,
onChange: props.onValueChange,
});The parent’s value wins whenever it is defined, and onChange hears every change, in both modes.
Built on this pattern in Turtlekit: Checkbox, Data table, Segmented control, Slider and Tabs.
When the field stops listening
Nothing can be typed into the field
It has a value and nothing that updates it. Pass onValueChange with a state setter, or pass defaultValue instead and let the field keep its text.
The field stops following its parent
The value became undefined, often through customer?.terms before the data loads, and the field fell back to its own text. Pass an empty string for an empty field, never undefined. React warns that an input changed from controlled to uncontrolled when a native input does this.
A new default doesn’t show
defaultValue is read on mount only. Give the field a key that changes with the thing the default comes from, or control the field from the parent.
Decide the owner before the props
The Brightline invoice went out wrong because a value had no clear owner: the field held it, while the page knew when it should change. Most bugs between a component and its parent are this one in another form. Asking who owns each value settles the props before they are written: the page that has to change the terms holds them, the field that only collects a purchase order number keeps it, and a shared field lets each page choose.