Skip to content

Instantly share code, notes, and snippets.

@tomschall
Last active November 7, 2025 19:11
Show Gist options
  • Select an option

  • Save tomschall/f340c4cfb34d45fb735324a558100ba7 to your computer and use it in GitHub Desktop.

Select an option

Save tomschall/f340c4cfb34d45fb735324a558100ba7 to your computer and use it in GitHub Desktop.

Controlled vs. Uncontrolled Components in React

1. Offizielle React-Definition (Formularebene)

In der React-Dokumentation bedeutet controlled:

Der Wert eines Elements wird vollständig durch den React-State kontrolliert.

Das Gegenstück ist uncontrolled:

Der Wert liegt im DOM (nicht in React-State) und wird über ref gelesen.

Beispiel – Controlled Input:

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

✅ Controlled: React-State steuert den Wert

Beispiel – Uncontrolled Input:

<input defaultValue="Tom" ref={inputRef} />

❌ Uncontrolled: DOM steuert den Wert, React beobachtet nur


2. Erweiterte architektonische Sicht (Komponentenebene)

Bei komplexeren UI-Komponenten (z. B. Tabs, Accordions, Modals, Dropdowns) geht es um die Frage, wer den React-State kontrolliert.

Typ Wer kontrolliert den Zustand? Beispiel
Controlled (by parent) Der Parent hält den State und übergibt ihn über Props <Tabs currentTab={tab} onTabChange={setTab} />
Self-managed (intern) Die Komponente verwaltet ihren Zustand intern über React-State <Tabs defaultTab={0} />

Wichtig: Eine self-managed Komponente ist nicht uncontrolled. Sie ist vollständig von React kontrolliert – nur eben innerhalb ihrer eigenen Komponente.


3. Beispiele

Self-managed (intern kontrolliert)

function Tabs({ items }) {
  const [tab, setTab] = useState(0);
  return (
    <ul>
      {items.map((item, i) => (
        <li key={i} onClick={() => setTab(i)}>
          {i === tab ? <b>{item}</b> : item}
        </li>
      ))}
    </ul>
  );
}

Controlled by parent

function App() {
  const [tab, setTab] = useState(0);
  return <Tabs items={['A', 'B', 'C']} currentTab={tab} onTabChange={setTab} />;
}

Beide Varianten verwenden React-State:

  • Im ersten Fall liegt er in der Komponente selbst → self-managed.
  • Im zweiten Fall liegt er im Parent → controlled by parent.

4. Fazit

Begriff Wer kontrolliert den Wert? Beispiel React-State involviert?
Controlled Input React-State <input value={name} onChange={...} />
Uncontrolled Input DOM (via ref) <input defaultValue="Tom" ref={ref} />
Self-managed Component Komponente selbst (React-State intern) <Tabs defaultTab={0} />
Controlled Component (by parent) Parent-Komponente <Tabs currentTab={tab} onTabChange={setTab} />

💡 Kernaussage:
Self-managed Komponenten sind nicht uncontrolled.
Sie sind innerhalb von React kontrolliert (verwenden React-State), werden aber nicht vom Parent gesteuert.
Nur uncontrolled Inputs (z. B. <input defaultValue="..." ref={...} />) sind wirklich außerhalb der Kontrolle von React.


5. Best Practices & Anti-Patterns

5.1 Entscheidungs-Guide (kurz)

  • Controlled Input (Formular): wenn du Live-Validierung, Maskierung, oder synchronen UI-State brauchst.
  • Uncontrolled Input: für simple Forms, Performance (große Formulare), oder wenn du den Wert erst beim Submit brauchst.
  • Self-managed Widget (Tabs/Accordion/Modal): wenn das Widget eigenständig funktionieren soll.
  • Controlled by parent (Widget): wenn der Parent den Zustand zentral steuern/teilen/synchronisieren muss.

5.2 Do: saubere Patterns

A) Controlled Input (Docs-konform)

function NameField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

B) Uncontrolled Input (einfach & performant)

function EmailField() {
  const ref = useRef<HTMLInputElement>(null);
  const onSubmit = () => alert(ref.current?.value);
  return (
    <form onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
      <input defaultValue="tom@example.com" ref={ref} />
      <button>Senden</button>
    </form>
  );
}

C) Self-managed Tabs (intern, mit Clamping)

function Tabs({ items, defaultTab = 0 }: { items: string[]; defaultTab?: number }) {
  const [tab, setTab] = useState(defaultTab);
  useEffect(() => {
    // State gültig halten, nicht Props spiegeln
    setTab((t) => Math.min(t, Math.max(items.length - 1, 0)));
  }, [items]);
  return (
    <ul role="tablist" aria-orientation="horizontal">
      {items.map((label, i) => (
        <li key={label}>
          <button role="tab" aria-selected={i === tab} onClick={() => setTab(i)}>
            {label}
          </button>
        </li>
      ))}
    </ul>
  );
}

D) Controlled Tabs (Parent steuert)

function App() {
  const [tab, setTab] = useState(0);
  return <Tabs items={["A","B","C"]} currentTab={tab} onTabChange={setTab} />;
}

E) Explizites Reset-Signal (statt Props→State-Mirroring)

// Parent
<Tabs items={items} defaultTab={0} resetKey={filterVersion} />

// Child
function Tabs({ items, defaultTab = 0, resetKey }: Props) {
  const [tab, setTab] = useState(defaultTab);
  useEffect(() => { setTab(defaultTab); }, [resetKey, defaultTab]);
  // ...
}

F) Layout-Effekte ohne setTimeout

const check = useCallback(() => { /* misst/aktualisiert Scroll-Schatten */ }, []);
const scrollTo = useCallback((i: number) => {
  const el = listRef.current?.children[i] as HTMLElement | undefined;
  if (!el) return;
  requestAnimationFrame(() => {
    el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
    requestAnimationFrame(check);
  });
}, [check]);

G) ARIA & Tastatur-Navigation für Tabs

<ul role="tablist" aria-orientation="horizontal">
  {/* buttons mit role="tab" und aria-selected */}
</ul>
// Keydown: Left/Right/Home/End auf den fokussierten Tab-Button

5.3 Don’t: Anti-Patterns (mit Alternativen)

  1. Props → State spiegeln (nach Mount)
// ❌ Anti-Pattern
const [tab, setTab] = useState(currentTab);
useEffect(() => setTab(currentTab), [currentTab]);
// ✅ Alternative
// a) Controlled by parent: nutze currentTab direkt
// b) Self-managed: nur defaultTab beim Mount verwenden
  1. „Halb controlled, halb self-managed“ (zwei Quellen der Wahrheit)
// ❌ onClick setzt internen State, Effekt setzt aus Props zurück → flakey

Fix: Entweder controlled oder self-managed. Hybride nur mit explizitem Reset-Signal (resetKey).

  1. setState im Render
// ❌ im Body der Komponente abhängig von Props setState aufrufen → Render-Loop-Risiko

Fix: Ableitungen direkt berechnen oder Effekte gezielt einsetzen.

  1. setTimeout für Layout/Scroll verwenden
// ❌ fragil, timing-abhängig

Fix: requestAnimationFrame/ResizeObserver nutzen; danach messen/aktualisieren.

  1. value und defaultValue mischen
// ❌ führt zu kontrolliert↔unkontrolliert-Wechselwarnungen

Fix: Entweder value oder defaultValue – nicht beides.

  1. Kein Clamping bei Listendaten
// ❌ Index zeigt ins Leere, wenn items schrumpfen

Fix: Beim items-Wechsel Index auf [0, items.length-1] begrenzen.

  1. Fehlende ARIA/Tastaturunterstützung
  • Tabs ohne role="tablist", role="tab", aria-selected
  • Keine Pfeiltasten-/Home/End-Unterstützung → schlechtere Accessibility

5.4 Entscheidungs-Checkliste vor Implementierung

  • Brauche ich zentralen Zustand über mehrere Komponenten? → Controlled by parent
  • Soll das Widget autark funktionieren? → Self-managed
  • Muss ein Input live validiert/formatiert werden? → Controlled Input
  • Ist es ein großes Formular mit seltenem Lesen der Werte? → Uncontrolled Inputs
  • Gibt es potenzielles Out-of-bounds (Filter, Sort, dynamische Items)? → Clamping einplanen
  • Gibt es Layout-Effekte (Scroll/Schatten/Resize)? → rAF/ResizeObserver statt Timeouts

5.5 Kurzfazit

Keine zwei Wahrheiten. Entscheide dich pro Komponente für eine Quelle der Wahrheit.
Inputs: controlled (React) vs. uncontrolled (DOM).
Widgets: controlled by parent vs. self-managed.
Vermeide Props→State-Sync nach dem Mount, nutze default*, resetKey, Clamping, rAF und ARIA – dann ist die UI stabil, zugänglich und gut testbar.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment