Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Controlled and uncontrolled components in React

Turtlekit Editorial Team

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.

controlledNewInvoicestate: termsDaysFieldshows valuevalueonValueChangeuncontrolledCustomerTermsreads it on saveDaysFieldstate: own textdefaultValueread once, on mount
The same field, two owners. Controlled, the page holds the text and the field reports each change to it. Uncontrolled, the field holds the text and the page only sets where it starts.

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:

The terms and the due date, issued on 15 November 2026
MomenttermsDescription
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

Controlled and uncontrolled, side by side
QuestionControlledUncontrolled
Who holds the valueThe parent’s stateThe component
Can the parent change it laterAt any timeOnly by remounting it
What re-renders on a keystrokeThe parent and its childrenThe component alone
How the page reads itFrom its own stateFrom 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.

The state reducer and prop getters in React

When the parent needs a say over every change, not only the value it ends on.

Lifting state up in React

When two components need the same value and the question is where to put it.

Adjusting state when a prop changes

When a component’s own state has to follow a prop that changed.

useActionState and form Actions in React

When the form submits through an Action and resets its fields.