26 useImperativeHandle – Imperative APIs kapseln

React ist deklarativ. Wir beschreiben, was die UI zeigen soll, nicht wie sie es tun soll. Props fließen nach unten, Events nach oben. Komponenten sind Funktionen von Props zu UI. Dieses Modell ist elegant, vorhersagbar und hat React zu dem gemacht, was es ist.

Und dann gibt es useImperativeHandle – einen Hook, der bewusst gegen dieses Paradigma verstößt. Er erlaubt Parent-Komponenten, Methoden auf Child-Komponenten aufzurufen. Direkt. Imperativ. Das fühlt sich zunächst falsch an, und das ist gut so. Diese Unbehaglichkeit ist ein Warnsignal: Hier verlassen wir sicheres Terrain.

Aber manchmal ist genau das notwendig. Das Web ist fundamentally imperativ. DOM-APIs wie focus(), play(), scrollIntoView() sind Befehle, keine Zustandsbeschreibungen. Es gibt keinen deklarativen Weg zu sagen “dieses Element ist fokussiert” oder “dieses Video spielt gerade”. Diese Operationen sind Aktionen, und Aktionen erfordern imperative Kontrolle.

useImperativeHandle ist React’s Antwort auf dieses Problem. Kein Allheilmittel, sondern ein gezieltes Werkzeug für spezifische Szenarien, wo die deklarative Abstraktion bröckelt.

26.1 Das Problem: Deklarativ trifft imperativ

Stellen wir uns eine Custom-Input-Komponente vor, die komplexe Validierung, Formatierung und verschiedene Zustände verwaltet. Die Parent-Komponente möchte, dass dieses Input bei einem bestimmten Event fokussiert wird – etwa wenn ein Formular-Fehler auftritt.

Der naive Ansatz könnte so aussehen:

// ❌ Funktioniert nicht
function CustomInput({ shouldFocus }: Props) {
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    if (shouldFocus) {
      inputRef.current?.focus();
    }
  }, [shouldFocus]);
  
  return <input ref={inputRef} />;
}

// Parent muss State verwalten
function Form() {
  const [shouldFocus, setShouldFocus] = useState(false);
  
  const handleError = () => {
    setShouldFocus(true);
    // Problem: Wie resetten wir shouldFocus?
  };
}

Das funktioniert einmal. Aber wie fokussiert man das Input ein zweites Mal? shouldFocus ist bereits true. Man müsste es auf false setzen, einen Render-Cycle warten, dann wieder auf true. Das ist umständlich und fehleranfällig.

Das eigentliche Problem: Wir versuchen, eine Aktion als Zustand zu modellieren. Fokus ist kein Zustand, sondern ein Befehl. “Fokussiere jetzt!” ist imperativ, nicht deklarativ.

26.2 Die Lösung: Refs mit kontrollierter API

useImperativeHandle schafft eine Brücke. Er erlaubt es einer Komponente, eine imperative API zu definieren, die über eine Ref zugänglich ist. Die Parent-Komponente erhält keine volle Kontrolle über die Child-Komponente, sondern nur Zugriff auf explizit bereitgestellte Methoden.

Das Pattern besteht aus drei Teilen:

1. TypeScript-Interface für die API

Definiert, welche Methoden nach außen sichtbar sind.

interface CustomInputHandle {
  focus: () => void;
  reset: () => void;
  getValue: () => string;
}

2. forwardRef für Ref-Weiterleitung

forwardRef erlaubt es einer Komponente, eine Ref von ihrem Parent zu empfangen. Normalerweise können Funktionskomponenten keine Refs empfangen – forwardRef macht sie ref-fähig.

const CustomInput = forwardRef<CustomInputHandle, Props>((props, ref) => {
  // Komponenten-Logik
});

3. useImperativeHandle zum Definieren der API

Innerhalb der Komponente definiert useImperativeHandle, was über die Ref zugänglich ist.

useImperativeHandle(ref, () => ({
  focus: () => inputRef.current?.focus(),
  reset: () => setValue(''),
  getValue: () => value
}), [value]);

Die vollständige Implementierung:

interface CustomInputHandle {
  focus: () => void;
  reset: () => void;
  getValue: () => string;
}

interface CustomInputProps {
  defaultValue?: string;
  onValueChange?: (value: string) => void;
}

const CustomInput = forwardRef<CustomInputHandle, CustomInputProps>(
  ({ defaultValue = '', onValueChange }, ref) => {
    const [value, setValue] = useState(defaultValue);
    const inputRef = useRef<HTMLInputElement>(null);
    
    useImperativeHandle(ref, () => ({
      focus: () => {
        inputRef.current?.focus();
      },
      reset: () => {
        setValue('');
        onValueChange?.('');
      },
      getValue: () => value
    }), [value, onValueChange]);
    
    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      setValue(newValue);
      onValueChange?.(newValue);
    };
    
    return <input ref={inputRef} value={value} onChange={handleChange} />;
  }
);

Die Parent-Komponente kann jetzt imperative Methoden aufrufen:

function Form() {
  const inputRef = useRef<CustomInputHandle>(null);
  
  const handleError = () => {
    inputRef.current?.focus();
  };
  
  const handleReset = () => {
    inputRef.current?.reset();
  };
  
  const handleSubmit = () => {
    const value = inputRef.current?.getValue();
    console.log('Submitted:', value);
  };
  
  return (
    <div>
      <CustomInput ref={inputRef} onValueChange={v => console.log(v)} />
      <button onClick={handleError}>Focus</button>
      <button onClick={handleReset}>Reset</button>
      <button onClick={handleSubmit}>Submit</button>
    </div>
  );
}

26.3 Die Anatomie: forwardRef und useImperativeHandle

Die Zusammenarbeit zwischen forwardRef und useImperativeHandle ist essentiell. Ohne forwardRef kann eine Funktionskomponente keine Ref empfangen. forwardRef erweitert die Komponenten-Signatur um einen zweiten Parameter – die Ref.

// Normale Komponente
function Component(props: Props) { /* ... */ }

// Mit forwardRef
const Component = forwardRef<RefType, Props>((props, ref) => { /* ... */ });

Der generische Typ-Parameter ist wichtig. Der erste (RefType) definiert den Typ der Ref – also was über ref.current zugänglich ist. Der zweite (Props) definiert die Props der Komponente.

useImperativeHandle nimmt drei Parameter:

  1. Die Ref: Die vom Parent übergebene Ref
  2. Eine Factory-Funktion: Gibt das API-Objekt zurück
  3. Dependencies: Wie bei useEffect oder useMemo
useImperativeHandle(
  ref,                          // Die Ref
  () => ({                      // Factory-Funktion
    focus: () => { /* ... */ },
    reset: () => { /* ... */ }
  }),
  [dependencies]                // Dependencies
);

Die Factory-Funktion wird bei jedem Rendering ausgeführt, wenn sich Dependencies ändern. Das API-Objekt wird dann auf ref.current gesetzt. Die Dependencies sind wichtig – wenn die Methoden auf State oder Props zugreifen, müssen diese gelistet sein.

// ❌ Falsch: value fehlt in Dependencies
useImperativeHandle(ref, () => ({
  getValue: () => value  // Closure über value
}), []);  // value nicht gelistet!

// ✓ Richtig: value in Dependencies
useImperativeHandle(ref, () => ({
  getValue: () => value
}), [value]);

Ohne korrekte Dependencies arbeiten die Methoden mit stale values – veralteten Werten aus früheren Renderings.

26.4 Legitime Anwendungsfälle

useImperativeHandle ist für spezifische Szenarien gedacht. Die meisten dieser Szenarien involvieren DOM-Operationen, die keine deklarative Entsprechung haben.

26.4.1 1. Fokus-Management

Der klassische Fall. Accessibility-Richtlinien erfordern oft programmatische Fokus-Kontrolle.

interface ModalHandle {
  focus: () => void;
  close: () => void;
}

const Modal = forwardRef<ModalHandle, ModalProps>(
  ({ children, onClose }, ref) => {
    const modalRef = useRef<HTMLDivElement>(null);
    const closeButtonRef = useRef<HTMLButtonElement>(null);
    
    useImperativeHandle(ref, () => ({
      focus: () => {
        closeButtonRef.current?.focus();
      },
      close: () => {
        onClose();
      }
    }), [onClose]);
    
    return (
      <div ref={modalRef} role="dialog">
        {children}
        <button ref={closeButtonRef} onClick={onClose}>
          Close
        </button>
      </div>
    );
  }
);

// Verwendung
function App() {
  const modalRef = useRef<ModalHandle>(null);
  const [isOpen, setIsOpen] = useState(false);
  
  const openModal = () => {
    setIsOpen(true);
    // Nach Rendering: Focus auf Modal
    setTimeout(() => modalRef.current?.focus(), 0);
  };
  
  return (
    <>
      <button onClick={openModal}>Open Modal</button>
      {isOpen && (
        <Modal ref={modalRef} onClose={() => setIsOpen(false)}>
          Modal Content
        </Modal>
      )}
    </>
  );
}

26.4.2 2. Media-Kontrollen

Video und Audio haben imperative APIs. play(), pause(), seek() sind Befehle.

interface VideoPlayerHandle {
  play: () => void;
  pause: () => void;
  seek: (time: number) => void;
  getCurrentTime: () => number;
}

const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
  ({ src, onTimeUpdate }, ref) => {
    const videoRef = useRef<HTMLVideoElement>(null);
    
    useImperativeHandle(ref, () => ({
      play: () => {
        videoRef.current?.play();
      },
      pause: () => {
        videoRef.current?.pause();
      },
      seek: (time: number) => {
        if (videoRef.current) {
          videoRef.current.currentTime = time;
        }
      },
      getCurrentTime: () => {
        return videoRef.current?.currentTime ?? 0;
      }
    }), []);
    
    return (
      <video
        ref={videoRef}
        src={src}
        onTimeUpdate={e => onTimeUpdate?.(e.currentTarget.currentTime)}
      />
    );
  }
);

26.4.3 3. Scroll-Operationen

Scroll ist eine Aktion, kein Zustand. scrollIntoView(), scrollTo() sind imperative DOM-Methoden.

interface ScrollableListHandle {
  scrollToTop: () => void;
  scrollToBottom: () => void;
  scrollToItem: (index: number) => void;
}

const ScrollableList = forwardRef<ScrollableListHandle, ListProps>(
  ({ items }, ref) => {
    const containerRef = useRef<HTMLDivElement>(null);
    const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
    
    useImperativeHandle(ref, () => ({
      scrollToTop: () => {
        containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
      },
      scrollToBottom: () => {
        containerRef.current?.scrollTo({
          top: containerRef.current.scrollHeight,
          behavior: 'smooth'
        });
      },
      scrollToItem: (index: number) => {
        itemRefs.current[index]?.scrollIntoView({
          behavior: 'smooth',
          block: 'nearest'
        });
      }
    }), []);
    
    return (
      <div ref={containerRef} style={{ height: 400, overflow: 'auto' }}>
        {items.map((item, i) => (
          <div key={item.id} ref={el => itemRefs.current[i] = el}>
            {item.text}
          </div>
        ))}
      </div>
    );
  }
);

26.4.4 4. Animation-Trigger

JavaScript-Animationen erfordern oft imperative Steuerung.

interface AnimatedBoxHandle {
  startAnimation: () => void;
  stopAnimation: () => void;
  resetAnimation: () => void;
}

const AnimatedBox = forwardRef<AnimatedBoxHandle, AnimatedBoxProps>(
  ({ children }, ref) => {
    const boxRef = useRef<HTMLDivElement>(null);
    const animationRef = useRef<Animation | null>(null);
    
    useImperativeHandle(ref, () => ({
      startAnimation: () => {
        if (!boxRef.current) return;
        
        animationRef.current = boxRef.current.animate([
          { transform: 'translateX(0px)' },
          { transform: 'translateX(300px)' }
        ], {
          duration: 1000,
          easing: 'ease-in-out'
        });
      },
      stopAnimation: () => {
        animationRef.current?.pause();
      },
      resetAnimation: () => {
        animationRef.current?.cancel();
      }
    }), []);
    
    return <div ref={boxRef}>{children}</div>;
  }
);

26.5 Wann NICHT verwenden

Die Versuchung ist groß, useImperativeHandle als Allzweck-Tool zu missbrauchen. Aber für die meisten Szenarien gibt es bessere, deklarative Alternativen.

Nicht für State-Management verwenden

Ein häufiger Fehler ist der Versuch, State über imperative Methoden zu verwalten.

// ❌ Falsch: State via imperative API
interface CounterHandle {
  increment: () => void;
  decrement: () => void;
  setValue: (value: number) => void;
}

const Counter = forwardRef<CounterHandle, CounterProps>((props, ref) => {
  const [count, setCount] = useState(0);
  
  useImperativeHandle(ref, () => ({
    increment: () => setCount(c => c + 1),
    decrement: () => setCount(c => c - 1),
    setValue: (value) => setCount(value)
  }), []);
  
  return <div>{count}</div>;
});

// ✓ Richtig: State via Props
interface CounterProps {
  value: number;
  onIncrement: () => void;
  onDecrement: () => void;
  onValueChange: (value: number) => void;
}

function Counter({ value, onIncrement, onDecrement }: CounterProps) {
  return (
    <div>
      {value}
      <button onClick={onIncrement}>+</button>
      <button onClick={onDecrement}>-</button>
    </div>
  );
}

State sollte über Props fließen. Die Parent-Komponente verwaltet den State, die Child-Komponente rendert ihn. Das ist React’s Kern-Paradigma.

Nicht als Ersatz für Callbacks

// ❌ Falsch: Callback via imperative API
interface FormHandle {
  onSubmit: (data: FormData) => void;
}

// ✓ Richtig: Callback als Prop
interface FormProps {
  onSubmit: (data: FormData) => void;
}

Callbacks sind bereits imperativ – die Parent-Komponente übergibt eine Funktion, die Child ruft sie auf. Kein Grund für useImperativeHandle.

Nicht für einfachen DOM-Zugriff

Wenn Sie nur DOM-Referenzen benötigen, brauchen Sie keinen useImperativeHandle.

// ❌ Überkomplex
interface InputHandle {
  getElement: () => HTMLInputElement | null;
}

const Input = forwardRef<InputHandle>((props, ref) => {
  const inputRef = useRef<HTMLInputElement>(null);
  
  useImperativeHandle(ref, () => ({
    getElement: () => inputRef.current
  }), []);
  
  return <input ref={inputRef} />;
});

// ✓ Einfacher: Direkte Ref-Weiterleitung
const Input = forwardRef<HTMLInputElement>((props, ref) => {
  return <input ref={ref} />;
});

forwardRef kann Refs direkt an DOM-Elemente weiterleiten. Kein useImperativeHandle nötig.

26.6 Architektur-Überlegungen

useImperativeHandle hat Implikationen für die Architektur. Er erzeugt eine bidirektionale Abhängigkeit zwischen Parent und Child – der Parent kennt die Child-API, die Child weiß, dass sie aufgerufen werden kann.

Aspekt Deklarativ (Props) Imperativ (useImperativeHandle)
Datenfluss Unidirektional (Props down, Events up) Bidirektional (Props down, Methodenaufrufe down)
Testbarkeit Einfach (Props setzen, Output prüfen) Komplexer (Refs mocken, Methoden aufrufen)
Debugging Klar (Props-Flow in DevTools) Schwieriger (Imperative Aufrufe nicht sichtbar)
Wiederverwendbarkeit Hoch (Komponente ist selbstständig) Niedriger (Erwartung an Parent-Steuerung)
Vorhersagbarkeit Hoch (Gleiche Props → Gleiches Rendering) Niedriger (Methoden können jederzeit aufgerufen werden)

Die Entscheidung für useImperativeHandle sollte bewusst getroffen werden. Fragen Sie sich:

Wenn die Antworten “Ja”, “Nein”, “Ja” sind, ist useImperativeHandle wahrscheinlich die richtige Wahl.

26.7 Best Practices und Patterns

1. Minimale API-Oberfläche

Exponieren Sie nur, was wirklich nötig ist. Jede Methode in der API ist ein Vertrag, der gepflegt werden muss.

// ❌ Zu viel exponiert
useImperativeHandle(ref, () => ({
  focus, blur, select, setSelectionRange, getValue, setValue, 
  reset, validate, clearError, setError, ...
}), [/* ... */]);

// ✓ Minimal, fokussiert
useImperativeHandle(ref, () => ({
  focus: () => inputRef.current?.focus(),
  reset: () => setValue('')
}), []);

2. Dokumentation in TypeScript

Nutzen Sie JSDoc für API-Dokumentation.

interface FormHandle {
  /** Fokussiert das erste Input-Feld mit Fehler */
  focusFirstError: () => void;
  
  /** Setzt alle Felder auf ihre Default-Werte zurück */
  reset: () => void;
  
  /** Gibt die aktuellen Form-Werte zurück */
  getValues: () => FormValues;
}

3. Defensive Programmierung

Gehen Sie davon aus, dass Methoden zu ungünstigen Zeitpunkten aufgerufen werden.

useImperativeHandle(ref, () => ({
  focus: () => {
    if (!inputRef.current) {
      console.warn('Cannot focus: ref not attached');
      return;
    }
    inputRef.current.focus();
  }
}), []);

4. Kombinieren Sie mit useCallback

Wenn Methoden komplex sind, memoizieren Sie sie.

const handleFocus = useCallback(() => {
  // Komplexe Logik
}, [dependencies]);

useImperativeHandle(ref, () => ({
  focus: handleFocus
}), [handleFocus]);

useImperativeHandle ist ein Spezialwerkzeug für spezielle Probleme. Er ist nicht Teil des normalen React-Workflows, sondern die Ausnahme – der Notausgang, wenn deklarative Patterns nicht ausreichen. Richtig eingesetzt, ermöglicht er elegante Lösungen für DOM-bezogene Operationen. Falsch eingesetzt, führt er zu schwer wartbarem Code, der React’s Kernprinzipien untergräbt. Die Kunst liegt darin, die Grenze zu erkennen.