Start HereOrdered learning track

Controlled and Uncontrolled Components

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 023

Controlled and uncontrolled components as an ownership contract: who owns value, who owns transition, when to synchronize, how to design safe component APIs, and how to avoid double source of truth in production React systems.

7 min read1309 words
PrevNext
Lesson 23123 lesson track01–23 Start Here
#react#hooks#state-management#component-composition+2 more

Part 023 — Controlled and Uncontrolled Components

Controlled vs uncontrolled bukan sekadar topik form input. Ini adalah topik state ownership.

Pertanyaan intinya:

Siapa pemilik value saat ini?
Siapa yang boleh mengubah value?
Kapan perubahan dianggap committed?
Apakah parent perlu tahu setiap perubahan atau hanya hasil akhirnya?
Apakah komponen ini harus bisa dikontrol dari luar?
Apakah komponen ini boleh menyimpan draft internal?

Kalau jawaban-jawaban ini tidak jelas, UI akan menghasilkan bug klasik:

input tiba-tiba reset
cursor lompat
state parent dan child beda
komponen kadang controlled kadang uncontrolled
form kehilangan draft user
modal/tabs/table punya dua source of truth

Controlled/uncontrolled adalah kontrak arsitektur, bukan preferensi gaya kode.


1. Definisi Praktis

Controlled component

Sebuah komponen disebut controlled ketika state pentingnya dimiliki oleh parent dan child hanya menerima state + mengirim event perubahan.

function SearchBox({ value, onValueChange }: {
  value: string;
  onValueChange: (next: string) => void;
}) {
  return (
    <input
      value={value}
      onChange={(event) => onValueChange(event.target.value)}
    />
  );
}

Di sini SearchBox tidak memutuskan nilai akhirnya sendiri. Ia hanya menjadi view + event emitter.

State flow-nya:

Invariant:

Tampilan input harus selalu merepresentasikan parent state.

Uncontrolled component

Sebuah komponen disebut uncontrolled ketika state pentingnya disimpan internal, bisa oleh DOM atau oleh state lokal component.

function SearchBox() {
  return <input defaultValue="initial query" />;
}

Untuk membaca nilai, caller biasanya memakai ref, FormData, submit event, atau API imperative terbatas.

function SearchForm() {
  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    const query = String(formData.get('query') ?? '');
    console.log(query);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="query" defaultValue="react" />
      <button type="submit">Search</button>
    </form>
  );
}

State flow-nya:

Invariant:

React tidak harus tahu setiap intermediate keystroke.

2. Controlled/Uncontrolled di React DOM

Untuk native form elements, React punya perbedaan penting:

ElementControlled propUncontrolled initial prop
<input type="text" />valuedefaultValue
<textarea />valuedefaultValue
<select />valuedefaultValue
checkbox/radiocheckeddefaultChecked

Aturan produksi yang tidak boleh dilanggar:

Jangan membuat satu input berubah dari uncontrolled menjadi controlled selama lifetime yang sama.
Jangan membuat satu input berubah dari controlled menjadi uncontrolled selama lifetime yang sama.

Bad:

function EmailInput({ email }: { email?: string }) {
  return <input value={email} onChange={() => {}} />;
}

Kenapa buruk?

render pertama: email undefined -> React menganggap uncontrolled
render berikutnya: email string -> React menganggap controlled

Correct:

function EmailInput({ email }: { email?: string }) {
  return <input value={email ?? ''} onChange={() => {}} />;
}

Untuk checkbox:

function AcceptTerms({ checked }: { checked?: boolean }) {
  return <input type="checkbox" checked={checked ?? false} onChange={() => {}} />;
}

Untuk uncontrolled:

<input defaultValue={initialEmail} />
<input type="checkbox" defaultChecked={initialAccepted} />

defaultValue dan defaultChecked hanya memberi nilai awal. Setelah itu DOM boleh mengelola perubahan sendiri.


3. Controlled/Uncontrolled Bukan Hanya Form

Pola ini berlaku untuk semua komponen stateful.

Tabs controlled

<Tabs value={activeTab} onValueChange={setActiveTab}>
  ...
</Tabs>

Parent mengontrol tab aktif.

Tabs uncontrolled

<Tabs defaultValue="overview">
  ...
</Tabs>

Tabs mengelola tab aktif sendiri.

<Dialog open={isOpen} onOpenChange={setIsOpen} />

Parent bisa buka/tutup dari route, permission, command palette, atau workflow.

<Dialog defaultOpen={false} />

Dialog mengatur state internal berdasarkan trigger/close button.

Data table row selection controlled

<DataTable
  rows={rows}
  selectedRowIds={selectedRowIds}
  onSelectedRowIdsChange={setSelectedRowIds}
/>

Useful saat selection harus sinkron dengan toolbar, batch action, URL, atau domain command.

Data table row selection uncontrolled

<DataTable rows={rows} defaultSelectedRowIds={[]} />

Useful saat selection hanya concern internal table.


4. Mental Model: Owner, View, Transition

Jangan mulai dari pertanyaan “controlled atau uncontrolled?”. Mulai dari model ini:

Owner      : pihak yang menyimpan source of truth
View       : pihak yang menampilkan source of truth
Transition : pihak yang memutuskan perubahan valid/tidak
Commit     : waktu perubahan dianggap final

Diagram:

Dalam controlled component:

Owner      = parent
View       = child
Transition = parent atau domain layer
Commit     = parent state update

Dalam uncontrolled component:

Owner      = child atau DOM
View       = child/DOM
Transition = child/DOM
Commit     = submit, blur, imperative read, atau internal event

Ini alasan controlled component lebih cocok untuk state yang butuh koordinasi. Parent bisa menjadi single source of truth.


5. Decision Matrix

KondisiLebih cocok
Parent harus tahu setiap perubahanControlled
State memengaruhi sibling lainControlled
State harus masuk URLControlled
State dipakai untuk query/filter serverControlled atau committed-controlled
State adalah draft lokal sementaraUncontrolled/local draft
Field sangat banyak dan parent tidak perlu tahu setiap keystrokeUncontrolled/form library/localized control
Butuh reset dari luarControlled atau key-based reset
Butuh validasi real-time lintas fieldControlled atau form state manager
Butuh integrasi native form submitUncontrolled bisa sangat efektif
Component library perlu fleksibelSupport both controlled and uncontrolled

Rule of thumb:

Control state yang perlu dikoordinasikan.
Jangan control state yang hanya noise lokal.

6. Controlled Component: Anatomy

Controlled component minimal punya dua prop:

value
onValueChange

Contoh search box:

type SearchBoxProps = {
  value: string;
  onValueChange: (nextValue: string) => void;
};

export function SearchBox({ value, onValueChange }: SearchBoxProps) {
  return (
    <input
      type="search"
      value={value}
      onChange={(event) => onValueChange(event.target.value)}
    />
  );
}

Caller:

function SearchPage() {
  const [query, setQuery] = React.useState('');

  return (
    <>
      <SearchBox value={query} onValueChange={setQuery} />
      <SearchResults query={query} />
    </>
  );
}

Kelebihan:

single source of truth
mudah sinkron dengan URL/query/cache/sibling
mudah reset dari parent
mudah audit perubahan state
mudah membuat invariant lintas komponen

Biaya:

parent re-render pada setiap perubahan
API lebih verbose
caller harus menyediakan state
bisa over-centralized kalau semua hal dikontrol parent

7. Uncontrolled Component: Anatomy

Uncontrolled component minimal punya initial value dan commit point.

type SearchFormProps = {
  defaultQuery?: string;
  onSearch: (query: string) => void;
};

export function SearchForm({ defaultQuery = '', onSearch }: SearchFormProps) {
  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const formData = new FormData(event.currentTarget);
    const query = String(formData.get('query') ?? '');

    onSearch(query);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="query" type="search" defaultValue={defaultQuery} />
      <button type="submit">Search</button>
    </form>
  );
}

Kelebihan:

lebih sedikit re-render
lebih dekat dengan native browser form model
bagus untuk draft input yang tidak perlu diketahui parent
bagus untuk form besar dengan commit on submit

Biaya:

state tidak langsung terlihat oleh React parent
lebih sulit sinkron dengan sibling/URL/server query
lebih sulit melakukan real-time validation lintas field
reset dari luar perlu key, ref, atau form reset

8. Anti-Pattern: Double Source of Truth

Bad:

function SearchBox({ value, onValueChange }: {
  value: string;
  onValueChange: (value: string) => void;
}) {
  const [localValue, setLocalValue] = React.useState(value);

  return (
    <input
      value={localValue}
      onChange={(event) => {
        setLocalValue(event.target.value);
        onValueChange(event.target.value);
      }}
    />
  );
}

Masalah:

value parent bisa berubah tetapi localValue tidak otomatis sinkron
sinkronisasi via effect akan menciptakan edge case cursor/reset
ada dua state untuk satu fakta yang sama

Kalau butuh local draft, namakan sebagai draft dan buat commit boundary eksplisit.

Correct draft pattern:

type EditableTitleProps = {
  value: string;
  onCommit: (nextValue: string) => void;
};

function EditableTitle({ value, onCommit }: EditableTitleProps) {
  const [draft, setDraft] = React.useState(value);

  React.useEffect(() => {
    setDraft(value);
  }, [value]);

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        onCommit(draft.trim());
      }}
    >
      <input value={draft} onChange={(event) => setDraft(event.target.value)} />
      <button type="submit">Save</button>
    </form>
  );
}

Ini masih punya sinkronisasi, tapi sekarang maknanya jelas:

value = committed source of truth dari parent
draft = editing buffer internal
onCommit = boundary perubahan final

Untuk mencegah overwrite draft saat user sedang edit, bisa tambahkan dirty guard.

function EditableTitle({ value, onCommit }: EditableTitleProps) {
  const [draft, setDraft] = React.useState(value);
  const [isDirty, setIsDirty] = React.useState(false);

  React.useEffect(() => {
    if (!isDirty) {
      setDraft(value);
    }
  }, [value, isDirty]);

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        onCommit(draft.trim());
        setIsDirty(false);
      }}
    >
      <input
        value={draft}
        onChange={(event) => {
          setDraft(event.target.value);
          setIsDirty(true);
        }}
      />
      <button type="submit">Save</button>
    </form>
  );
}

9. Controlled Shell, Uncontrolled Core

Kadang API luar harus controlled, tapi bagian dalam tetap punya state lokal untuk interaksi cepat.

Contoh: search autocomplete.

committed query       -> controlled by parent
input draft           -> local state
highlighted option    -> local state
is popup open         -> local or controlled depending on use case
selected value        -> controlled by parent

Diagram:

Ini bukan double source of truth jika tiap state punya makna berbeda.

Yang salah adalah dua state menyimpan fakta yang sama.


10. Designing Controllable Component APIs

Component library yang mature biasanya mendukung dua mode:

<Tabs value={value} onValueChange={setValue} />
<Tabs defaultValue="overview" />

API umum:

value            controlled current value
defaultValue     uncontrolled initial value
onValueChange    notification/request when value changes

Untuk boolean:

open
 defaultOpen
onOpenChange

Untuk checked:

checked
defaultChecked
onCheckedChange

Untuk selected collection:

selectedKeys
defaultSelectedKeys
onSelectedKeysChange

Prinsip naming:

DomainControlledUncontrolledEvent
valuevaluedefaultValueonValueChange
openopendefaultOpenonOpenChange
checkedcheckeddefaultCheckedonCheckedChange
selected row idsselectedRowIdsdefaultSelectedRowIdsonSelectedRowIdsChange
expanded keysexpandedKeysdefaultExpandedKeysonExpandedKeysChange

Jangan pakai nama yang kabur:

onChange    -> terlalu generic jika value domain spesifik
onClick     -> event DOM, bukan transition domain
setValue    -> mencampur implementation detail parent

Lebih baik:

onTabChange
onOpenChange
onSelectionChange
onDraftCommit
onFilterCommit

11. Build From Scratch: useControllableState

Kita ingin hook kecil untuk membangun component yang bisa controlled dan uncontrolled.

Target API:

const [value, setValue] = useControllableState({
  value: props.value,
  defaultValue: props.defaultValue,
  onChange: props.onValueChange,
});

Implementasi pertama:

function useControllableState<T>({
  value,
  defaultValue,
  onChange,
}: {
  value?: T;
  defaultValue: T;
  onChange?: (value: T) => void;
}) {
  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = React.useState(defaultValue);

  const currentValue = isControlled ? value : internalValue;

  const setValue = React.useCallback(
    (nextValue: T) => {
      if (!isControlled) {
        setInternalValue(nextValue);
      }

      onChange?.(nextValue);
    },
    [isControlled, onChange]
  );

  return [currentValue, setValue] as const;
}

Ini cukup untuk banyak kasus, tapi belum robust.

Problem:

Tidak mendukung updater function.
Tidak mendeteksi mode switch.
value undefined mungkin value valid untuk beberapa domain.
Callback bisa dipanggil meski value sama.

Versi lebih kuat:

type SetStateAction<T> = T | ((previousValue: T) => T);

type ControllableStateOptions<T> = {
  value: T | undefined;
  defaultValue: T;
  onChange?: (value: T) => void;
  isEqual?: (a: T, b: T) => boolean;
  componentName?: string;
};

function useControllableState<T>({
  value,
  defaultValue,
  onChange,
  isEqual = Object.is,
  componentName = 'Component',
}: ControllableStateOptions<T>) {
  const wasControlledRef = React.useRef(value !== undefined);
  const isControlled = value !== undefined;

  const [internalValue, setInternalValue] = React.useState(defaultValue);

  if (process.env.NODE_ENV !== 'production') {
    if (wasControlledRef.current !== isControlled) {
      console.error(
        `${componentName} changed from ${
          wasControlledRef.current ? 'controlled' : 'uncontrolled'
        } to ${isControlled ? 'controlled' : 'uncontrolled'}. ` +
          `Choose one mode for the component lifetime.`
      );
    }
  }

  const currentValue = isControlled ? (value as T) : internalValue;

  const setValue = React.useCallback(
    (next: SetStateAction<T>) => {
      const nextValue =
        typeof next === 'function'
          ? (next as (previousValue: T) => T)(currentValue)
          : next;

      if (isEqual(currentValue, nextValue)) {
        return;
      }

      if (!isControlled) {
        setInternalValue(nextValue);
      }

      onChange?.(nextValue);
    },
    [currentValue, isControlled, isEqual, onChange]
  );

  return [currentValue, setValue, isControlled] as const;
}

Usage:

type TabsProps = {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  children: React.ReactNode;
};

function Tabs({
  value,
  defaultValue = 'overview',
  onValueChange,
  children,
}: TabsProps) {
  const [activeTab, setActiveTab] = useControllableState({
    value,
    defaultValue,
    onChange: onValueChange,
    componentName: 'Tabs',
  });

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  );
}

Important nuance:

`value !== undefined` adalah konvensi umum.
Jika undefined adalah value valid untuk domain Anda, pakai prop eksplisit seperti `controlledValue` atau `isControlled` internal yang dihitung dari `hasOwnProperty`.

Contoh dengan presence check:

function hasProp(object: object, key: string) {
  return Object.prototype.hasOwnProperty.call(object, key);
}

Tapi untuk public component API, lebih baik desain domain agar undefined berarti “not provided”.


12. Controllable State Is Not Always Good

Mendukung controlled + uncontrolled menambah kompleksitas.

Gunakan dual-mode API ketika:

komponen adalah reusable primitive/library
caller berbeda punya kebutuhan berbeda
state sering perlu dikontrol dari luar
uncontrolled mode memberi ergonomi besar

Jangan gunakan dual-mode API ketika:

komponen hanya internal page-specific
state harus selalu dimiliki parent/domain
mode ganda membuat invariant kabur
team belum punya konvensi testing yang kuat

Untuk product-specific component, controlled-only sering lebih sederhana.

<CaseStatusFilter value={status} onValueChange={setStatus} />

Untuk design-system primitive, dual mode sering layak.

<Tabs defaultValue="details" />
<Tabs value={tabFromUrl} onValueChange={setTabFromUrl} />

13. Controlled Inputs and Performance

Controlled input membuat React state update pada tiap keystroke. Ini normal dan valid.

Masalah biasanya bukan input-nya, tapi parent tree terlalu besar.

Bad structure:

function DashboardPage() {
  const [query, setQuery] = React.useState('');

  return (
    <main>
      <SearchBox value={query} onValueChange={setQuery} />
      <HugeAnalyticsTree query={query} />
      <ExpensiveChartPanel />
      <AuditLogPanel />
    </main>
  );
}

Kalau seluruh page berat ikut re-render setiap keystroke, isolasi state.

function DashboardPage() {
  return (
    <main>
      <SearchSection />
      <ExpensiveChartPanel />
      <AuditLogPanel />
    </main>
  );
}

function SearchSection() {
  const [query, setQuery] = React.useState('');

  return (
    <>
      <SearchBox value={query} onValueChange={setQuery} />
      <HugeAnalyticsTree query={query} />
    </>
  );
}

Jika expensive result boleh lag sedikit dari input, pisahkan draft dan derived/deferred value.

function SearchSection() {
  const [query, setQuery] = React.useState('');
  const deferredQuery = React.useDeferredValue(query);

  return (
    <>
      <SearchBox value={query} onValueChange={setQuery} />
      <HugeAnalyticsTree query={deferredQuery} />
    </>
  );
}

Jangan membuat input value sendiri deferred. Input harus tetap responsif terhadap typing langsung.

Better mental model:

input draft      = immediate
expensive view   = deferred
server query     = debounced/committed/query-managed

14. Debounced Controlled State

Search sering membutuhkan dua state:

queryDraft       = apa yang user ketik sekarang
committedQuery   = query yang dipakai fetch/filter final

Implementation:

function SearchPage() {
  const [queryDraft, setQueryDraft] = React.useState('');
  const [committedQuery, setCommittedQuery] = React.useState('');

  React.useEffect(() => {
    const timeoutId = window.setTimeout(() => {
      setCommittedQuery(queryDraft.trim());
    }, 300);

    return () => window.clearTimeout(timeoutId);
  }, [queryDraft]);

  return (
    <>
      <SearchBox value={queryDraft} onValueChange={setQueryDraft} />
      <SearchResults query={committedQuery} />
    </>
  );
}

But note:

queryDraft dan committedQuery bukan duplicate state.
Mereka punya semantic berbeda.

Kalau Anda menyimpan query dan sameQueryButDelayed, itu buruk. Kalau Anda menyimpan draft dan committed, itu domain boundary.


15. Reset Semantics

Controlled reset:

function Parent() {
  const [value, setValue] = React.useState('initial');

  return (
    <>
      <SearchBox value={value} onValueChange={setValue} />
      <button onClick={() => setValue('')}>Reset</button>
    </>
  );
}

Uncontrolled reset with form:

function Form() {
  return (
    <form>
      <input name="email" defaultValue="a@example.com" />
      <button type="reset">Reset</button>
    </form>
  );
}

Uncontrolled reset with key:

function Parent() {
  const [version, setVersion] = React.useState(0);

  return (
    <>
      <UncontrolledEditor key={version} defaultValue="initial" />
      <button onClick={() => setVersion((v) => v + 1)}>Reset editor</button>
    </>
  );
}

key berarti remount. Gunakan ketika memang ingin membuang seluruh internal state subtree.


16. Imperative Read with Refs

Ref valid untuk membaca uncontrolled value pada event, bukan pada render.

Correct:

function EmailCapture() {
  const inputRef = React.useRef<HTMLInputElement | null>(null);

  function handleSubmit() {
    const email = inputRef.current?.value ?? '';
    console.log(email);
  }

  return (
    <>
      <input ref={inputRef} defaultValue="" />
      <button onClick={handleSubmit}>Submit</button>
    </>
  );
}

Bad:

function EmailCapture() {
  const inputRef = React.useRef<HTMLInputElement | null>(null);
  const email = inputRef.current?.value ?? '';

  return <p>Email is {email}</p>;
}

Kenapa buruk?

Mutasi ref tidak menyebabkan render.
Render tidak akan otomatis mengikuti value DOM.

Kalau value harus tampil reaktif di UI, jadikan controlled state.


17. Controlled Boundary for Domain Invariant

Controlled component berguna saat parent perlu enforce invariant.

Contoh: range filter min <= max.

Bad jika dua field punya state internal sendiri tanpa koordinasi.

Better:

type PriceRange = {
  min: number;
  max: number;
};

function PriceRangeFilter({
  value,
  onValueChange,
}: {
  value: PriceRange;
  onValueChange: (value: PriceRange) => void;
}) {
  function setMin(min: number) {
    onValueChange({
      min,
      max: Math.max(min, value.max),
    });
  }

  function setMax(max: number) {
    onValueChange({
      min: Math.min(value.min, max),
      max,
    });
  }

  return (
    <fieldset>
      <input
        type="number"
        value={value.min}
        onChange={(event) => setMin(Number(event.target.value))}
      />
      <input
        type="number"
        value={value.max}
        onChange={(event) => setMax(Number(event.target.value))}
      />
    </fieldset>
  );
}

Invariant diletakkan di transition, bukan di effect.


18. Component API: Request, Not Command

onValueChange sebaiknya dipahami sebagai request/event, bukan command mutlak.

Child berkata:

User requested nextValue.

Parent boleh:

accept
reject
normalize
clamp
delay
persist
sync to URL

Contoh normalize:

function UsernameFieldController() {
  const [username, setUsername] = React.useState('');

  return (
    <UsernameField
      value={username}
      onValueChange={(next) => {
        setUsername(next.toLowerCase().replace(/\s+/g, '-'));
      }}
    />
  );
}

Child tidak boleh berasumsi bahwa setelah memanggil onValueChange(next), value pasti langsung berubah ke next. Di concurrent rendering atau async state boundary, parent tetap owner final.


19. Illegal States

Controlled/uncontrolled design harus menghindari state yang tidak mungkin.

Bad API:

type ModalProps = {
  isOpen?: boolean;
  defaultOpen?: boolean;
  forceOpen?: boolean;
  closeOnEscape?: boolean;
  onClose?: () => void;
  onOpen?: () => void;
};

Problem:

isOpen dan forceOpen bisa konflik
onOpen/onClose tidak membawa next state
defaultOpen tidak jelas jika isOpen ada

Better:

type DialogProps = {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  modal?: boolean;
  closeOnEscape?: boolean;
};

Rules:

Jika `open` disediakan, Dialog controlled.
Jika `open` tidak disediakan, Dialog uncontrolled.
`defaultOpen` hanya initial state uncontrolled.
`onOpenChange` dipanggil ketika user/request mencoba mengubah state.

20. Failure Mode Catalog

20.1 Switching controlled/uncontrolled

Symptom:

React warning
input tidak konsisten
value hilang saat async data datang

Fix:

<input value={data?.name ?? ''} onChange={...} />

Atau render setelah data ada:

if (!data) return <Loading />;
return <input value={data.name} onChange={...} />;

20.2 Cursor jump

Symptom:

cursor lompat ke akhir saat mengetik

Common causes:

value dinormalisasi setiap keystroke
input remount karena key berubah
parent membuat string value dari source yang lag

Fix:

gunakan draft lokal untuk editing
commit normalization on blur/submit
pastikan key stabil

20.3 Lost draft on parent refresh

Symptom:

user sedang edit, lalu server refetch overwrite input

Fix:

bedakan committed value dan draft value
jangan sync prop ke draft ketika dirty
berikan conflict handling eksplisit

20.4 Parent over-control

Symptom:

semua field mengangkat state ke page
page lambat dan sulit dipahami

Fix:

co-locate draft state
commit hanya pada blur/submit
gunakan form state manager atau uncontrolled form untuk field besar

20.5 Uncontrolled value needed reactively

Symptom:

parent perlu enable/disable button berdasarkan input, tapi input uncontrolled

Fix:

jadikan controlled
atau commit state pada event tertentu

20.6 Controlled value without onChange

Symptom:

input tidak bisa diketik

Fix:

<input value={value} onChange={(e) => setValue(e.target.value)} />

Kalau read-only memang disengaja:

<input value={value} readOnly />

21. Testing Strategy

Controlled component test:

it('requests value change', async () => {
  const user = userEvent.setup();
  const onValueChange = vi.fn();

  render(<SearchBox value="" onValueChange={onValueChange} />);

  await user.type(screen.getByRole('searchbox'), 'react');

  expect(onValueChange).toHaveBeenCalled();
});

But better test with wrapper if you want full behavior:

function ControlledHarness() {
  const [value, setValue] = React.useState('');
  return <SearchBox value={value} onValueChange={setValue} />;
}

it('updates displayed value in controlled harness', async () => {
  const user = userEvent.setup();

  render(<ControlledHarness />);

  await user.type(screen.getByRole('searchbox'), 'react');

  expect(screen.getByRole('searchbox')).toHaveValue('react');
});

Uncontrolled form test:

it('submits uncontrolled field value', async () => {
  const user = userEvent.setup();
  const onSearch = vi.fn();

  render(<SearchForm onSearch={onSearch} />);

  await user.type(screen.getByRole('searchbox'), 'react');
  await user.click(screen.getByRole('button', { name: /search/i }));

  expect(onSearch).toHaveBeenCalledWith('react');
});

Controllable component test matrix:

controlled mode renders value prop
controlled mode calls onChange but does not mutate internally
uncontrolled mode uses defaultValue initially
uncontrolled mode updates internal state
mode switch emits development warning

22. Architecture Checklist

Sebelum memilih controlled/uncontrolled, jawab ini:

[ ] Apakah state perlu diketahui parent setiap saat?
[ ] Apakah state memengaruhi sibling lain?
[ ] Apakah state harus masuk URL/cache/server request?
[ ] Apakah state hanya draft lokal?
[ ] Apakah ada commit boundary: blur, submit, select, close?
[ ] Apakah parent boleh reject/normalize perubahan?
[ ] Apakah component library butuh controlled dan uncontrolled mode?
[ ] Apakah value bisa berubah dari undefined ke string/boolean?
[ ] Apakah defaultValue hanya dipakai sebagai initial value?
[ ] Apakah reset behavior eksplisit?
[ ] Apakah ada risiko lost draft saat server data refresh?
[ ] Apakah testing mencakup controlled dan uncontrolled behavior?

23. Practical Refactor Paths

Local uncontrolled to controlled

Before:

function FilterInput() {
  const [value, setValue] = React.useState('');
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

After:

function FilterInput({ value, onValueChange }: {
  value: string;
  onValueChange: (value: string) => void;
}) {
  return (
    <input
      value={value}
      onChange={(event) => onValueChange(event.target.value)}
    />
  );
}

Use when parent needs coordination.

Controlled to draft/commit

Before:

<SearchBox value={query} onValueChange={setQuery} />

After:

<SearchBox
  defaultValue={query}
  onCommit={(nextQuery) => setQuery(nextQuery)}
/>

Use when parent only needs committed result.

Duplicated state to explicit draft

Before:

const [value, setValue] = useState(props.value);

After:

const [draft, setDraft] = useState(props.value);
const isDirty = draft !== props.value;

Use when local value is semantically edit buffer.


24. Production Heuristics

Use controlled when:

state is part of product behavior
state crosses component boundary
state affects permissions/actions/query/cache
state must be serializable into URL
state must be audited/debugged centrally

Use uncontrolled when:

state is purely interaction detail
state is temporary draft
state only matters on submit
native form behavior is enough
performance and ergonomics are better with DOM ownership

Use controllable API when:

you are designing reusable primitive
consumers vary between simple and advanced usage
state sometimes must be externalized

Avoid controllable API when:

component has one clear owner
mode ambiguity will create bugs
team lacks tests for both modes

25. Summary

Controlled/uncontrolled adalah cara mendesain kepemilikan state.

Controlled   = parent owns source of truth.
Uncontrolled = component/DOM owns source of truth.
Draft        = local temporary state with explicit commit boundary.
Controllable = component supports both controlled and uncontrolled modes.

Yang perlu Anda jaga bukan sekadar “pakai value atau defaultValue”, tapi invariant ini:

Satu fakta harus punya satu owner.
Setiap perubahan harus punya transition boundary.
Setiap draft harus punya commit semantics.
Setiap component API harus membuat ownership terlihat dari luar.

Kalau ownership jelas, React state menjadi mudah diprediksi. Kalau ownership kabur, aplikasi akan terasa seperti kumpulan patch useEffect.


References

Lesson Recap

You just completed lesson 23 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.