20 useRef – Stabile Referenzen und direkter Zugriff

React verfolgt konsequent einen deklarativen Ansatz. Wir beschreiben, wie die Benutzeroberfläche aussehen soll, und React übernimmt die Transformation in DOM-Operationen. State-Änderungen lösen Re-Renderings aus, Props fließen durch die Komponentenhierarchie, und alles bleibt vorhersagbar und nachvollziehbar. Doch manchmal stößt dieser Ansatz an seine Grenzen. Manchmal brauchen wir einen direkten Draht zum DOM oder einen Platz für Werte, die zwischen Renderings überleben, ohne dabei die reaktive Maschinerie in Gang zu setzen.

Der useRef-Hook ist React’s Antwort auf diese Situationen. Er schafft eine Verbindung zwischen der deklarativen React-Welt und der imperativen DOM-Welt – oder bietet schlicht einen Speicherort für Daten, die React nicht interessieren müssen.

20.1 Die Anatomie einer Referenz

Eine Ref ist ein Container-Objekt mit einer einzigen Eigenschaft: current. Das Besondere an diesem Container liegt in seiner Stabilität. Während jedes Rendering neue Variablen erzeugt und alte Werte überschreibt, bleibt das Ref-Objekt identisch. React erstellt es beim ersten Rendering und behält es für die gesamte Lebensdauer der Komponente bei.

const inputRef = useRef<HTMLInputElement>(null);

// Das Objekt selbst ändert sich nie:
// { current: null } → { current: <input> } → { current: <input> }
// Gleiche Objektreferenz, nur current ändert sich

Der generische Typ-Parameter <HTMLInputElement> teilt TypeScript mit, welche Art von Wert in current gespeichert werden soll. Bei DOM-Referenzen verwenden wir die spezifischen HTML-Element-Typen, bei anderen Werten den entsprechenden Datentyp. Der Initialwert ist bei DOM-Referenzen immer null, weil das Element zum Zeitpunkt der Ref-Erstellung noch nicht existiert.

Diese Stabilität hat weitreichende Konsequenzen. Eine Änderung an current ist für React völlig unsichtbar. Kein Re-Rendering wird ausgelöst, keine Child-Komponenten werden aktualisiert, keine Effects werden getriggert. Die Änderung ist sofort und synchron verfügbar – aber eben nur innerhalb der laufenden Funktion, nicht im UI.

20.2 Der erste Anwendungsfall: DOM-Zugriff

Der klassische Einsatz von useRef ist der direkte Zugriff auf DOM-Elemente. React’s deklarativer Ansatz ist mächtig, aber für bestimmte Operationen gibt es keine deklarative Variante. Wie beschreibt man deklarativ “setze den Fokus auf dieses Input-Feld” oder “scrolle zu dieser Position”? Diese Operationen sind inhärent imperativ.

function AutofocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    // Direkter DOM-Zugriff über die Ref
    inputRef.current?.focus();
  }, []);
  
  return <input ref={inputRef} type="text" />;
}

Das ref-Attribut ist React’s Mechanismus, um die Verbindung herzustellen. Wir übergeben das Ref-Objekt, und React füllt current mit dem tatsächlichen DOM-Element, sobald es gemountet wurde. Der Optional-Chaining-Operator (?.) schützt uns vor dem Fall, dass current noch null ist – etwa während des initialen Renderings.

Die Anwendungsfälle für DOM-Refs sind vielfältig:

Focus-Management ist der häufigste Fall. Nach dem Öffnen eines Dialogs soll der Fokus auf ein bestimmtes Element gesetzt werden. Nach einem Fehler soll das fehlerhafte Feld fokussiert werden. Nach dem Absenden eines Formulars soll der Fokus zur nächsten Sektion springen.

function SearchDialog({ isOpen }: { isOpen: boolean }) {
  const searchRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    if (isOpen) {
      searchRef.current?.focus();
    }
  }, [isOpen]);
  
  return (
    <dialog open={isOpen}>
      <input ref={searchRef} type="search" placeholder="Suchen..." />
    </dialog>
  );
}

Scroll-Operationen erfordern ebenfalls imperativen Zugriff. Einen Chat automatisch nach unten scrollen, zu einer bestimmten Nachricht springen, oder infinite Scrolling implementieren – all das braucht direkten DOM-Zugriff.

function ChatMessages({ messages }: { messages: Message[] }) {
  const bottomRef = useRef<HTMLDivElement>(null);
  
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);
  
  return (
    <div>
      {messages.map(msg => <div key={msg.id}>{msg.text}</div>)}
      <div ref={bottomRef} />
    </div>
  );
}

Messungen von Element-Dimensionen oder -Positionen sind ein weiterer klassischer Fall. Die getBoundingClientRect-Methode liefert Position und Größe eines Elements – Informationen, die React nicht automatisch bereitstellt.

function MeasuredElement() {
  const elementRef = useRef<HTMLDivElement>(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  
  useEffect(() => {
    function updateDimensions() {
      const rect = elementRef.current?.getBoundingClientRect();
      if (rect) {
        setDimensions({ width: rect.width, height: rect.height });
      }
    }
    
    updateDimensions();
    window.addEventListener('resize', updateDimensions);
    return () => window.removeEventListener('resize', updateDimensions);
  }, []);
  
  return (
    <div ref={elementRef}>
      Größe: {dimensions.width} × {dimensions.height}
    </div>
  );
}

Integration von Third-Party-Libraries ist oft auf DOM-Zugriff angewiesen. Charting-Bibliotheken, Video-Player, Rich-Text-Editoren – viele Libraries erwarten eine DOM-Node als Parameter ihrer Initialisierung.

function ChartComponent({ data }: { data: number[] }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  
  useEffect(() => {
    if (!canvasRef.current) return;
    
    const chart = new ExternalChartLibrary(canvasRef.current, {
      data,
      type: 'bar'
    });
    
    return () => chart.destroy();
  }, [data]);
  
  return <canvas ref={canvasRef} />;
}

20.3 Der zweite Anwendungsfall: Persistente Werte

Die zweite Hauptanwendung von useRef ist weniger offensichtlich, aber ebenso wichtig: das Speichern von Werten, die zwischen Renderings erhalten bleiben sollen, ohne ein Re-Rendering auszulösen.

Der Unterschied zu useState ist subtil, aber fundamental. Beide speichern Werte über Renderings hinweg. Aber während useState reaktiv ist – Änderungen triggern Re-Renderings – ist useRef inert. Der Wert ändert sich, aber React nimmt keine Notiz davon.

Merkmal useState useRef
Löst Re-Rendering aus Ja Nein
Änderung verfügbar Nächstes Rendering Sofort
Änderung erfolgt Asynchron Synchron
Verwendung für UI Ja Nein
Verwendung für interne Logik Ja Ja

Diese Nicht-Reaktivität macht Refs perfekt für bestimmte Szenarien:

Timer-IDs und Interval-Handles sind der Klassiker. Ein setInterval gibt eine ID zurück, die wir später benötigen, um das Interval zu stoppen. Diese ID hat keine visuelle Repräsentation – sie ist reine interne Buchhaltung.

function Countdown({ seconds }: { seconds: number }) {
  const [remaining, setRemaining] = useState(seconds);
  const intervalRef = useRef<number | null>(null);
  
  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setRemaining(prev => {
        if (prev <= 1) {
          if (intervalRef.current) clearInterval(intervalRef.current);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);
    
    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, []);
  
  return <div>{remaining} Sekunden</div>;
}

Hier ist die Interval-ID in einer Ref gespeichert. Würden wir useState verwenden, würde jede Änderung an der ID ein Re-Rendering auslösen – völlig unnötig, da die ID nie im UI angezeigt wird.

Vorherige Werte von Props oder State können mit Refs getrackt werden. Manchmal müssen wir in einem Effect sowohl den aktuellen als auch den vorherigen Wert kennen, um zu bestimmen, was sich geändert hat.

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();
  
  useEffect(() => {
    ref.current = value;
  }, [value]);
  
  return ref.current;
}

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);
  
  return (
    <div>
      Aktuell: {count}, Vorher: {prevCount ?? 'keine'}
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

Der Effect läuft nach dem Rendering und aktualisiert die Ref mit dem aktuellen Wert. Beim nächsten Rendering ist dieser Wert dann der “vorherige” Wert. Clever, oder?

Render-Zähler für Debugging oder Performance-Monitoring lassen sich elegant mit Refs umsetzen. Der Zähler inkrementiert bei jedem Rendering, ohne seinerseits ein Re-Rendering auszulösen.

function ComponentWithRenderCount() {
  const renderCount = useRef(0);
  
  useEffect(() => {
    renderCount.current += 1;
  });
  
  return <div>Renderings: {renderCount.current}</div>;
}

Flag für mounted/unmounted-Status verhindert State-Updates nach dem Unmount – ein häufiges Problem bei asynchronen Operationen.

function DataLoader({ id }: { id: number }) {
  const [data, setData] = useState(null);
  const isMountedRef = useRef(true);
  
  useEffect(() => {
    isMountedRef.current = true;
    
    fetch(`/api/data/${id}`)
      .then(res => res.json())
      .then(data => {
        if (isMountedRef.current) {
          setData(data);
        }
      });
    
    return () => {
      isMountedRef.current = false;
    };
  }, [id]);
  
  return data ? <div>{data.title}</div> : <div>Lädt...</div>;
}

20.4 Das Timing von Ref-Zuweisungen

Ein wichtiger Aspekt beim Arbeiten mit DOM-Refs ist das Timing. Das DOM-Element existiert nicht sofort – es wird erst während des Commit-Phase von React erstellt. Die Ref-Zuweisung erfolgt nach dem Rendering, aber vor den Effects.

Während des initialen Renderings ist current noch null. Erst nach dem Commit wird es mit dem tatsächlichen Element gefüllt. Deshalb ist der Zugriff auf DOM-Refs typischerweise in useEffect gekapselt – dort ist garantiert, dass das Element existiert.

function TimingDemo() {
  const ref = useRef<HTMLDivElement>(null);
  
  // Hier ist ref.current noch null!
  console.log('Während Rendering:', ref.current);
  
  useEffect(() => {
    // Hier ist ref.current das DOM-Element
    console.log('In useEffect:', ref.current);
  });
  
  return <div ref={ref}>Test</div>;
}

Für Operationen, die vor dem Browser-Paint ausgeführt werden müssen (etwa um Layout-Verschiebungen zu vermeiden), gibt es useLayoutEffect. Dieser Hook läuft synchron nach DOM-Updates, aber vor dem Paint – perfekt für Messungen oder DOM-Manipulationen, die sofort sichtbar sein sollen.

20.5 Der Unterschied zur Mutation

Ein subtiler, aber wichtiger Punkt: Das Ändern von ref.current ist eine Mutation. Im Gegensatz zu React’s üblichem immutable-Update-Pattern mutieren wir hier direkt ein Objekt. Das ist völlig in Ordnung – Refs sind explizit dafür gedacht.

// useState: Immutable Update
const [count, setCount] = useState(0);
setCount(count + 1);  // Neuer Wert, alter bleibt unberührt

// useRef: Direkte Mutation
const countRef = useRef(0);
countRef.current += 1;  // Mutation des current-Werts

Diese Mutation ist synchron und sofort verfügbar. Es gibt keine asynchrone Update-Queue wie bei State. Der neue Wert steht in der nächsten Zeile Code zur Verfügung.

function DirectMutation() {
  const countRef = useRef(0);
  
  function handleClick() {
    countRef.current += 1;
    console.log(countRef.current);  // Neuer Wert ist sofort da
  }
  
  return <button onClick={handleClick}>Klick</button>;
}

Aber Achtung: Genau diese Sofortigkeit ist ein zweischneidiges Schwert. Änderungen an Refs während des Renderings können zu inkonsistentem Verhalten führen, besonders in Concurrent Mode oder bei Strict Mode mit doppeltem Rendering.

20.6 Callback-Refs für dynamische Szenarien

Neben dem direkten Ref-Objekt unterstützt React auch Callback-Refs – Funktionen, die als ref-Attribut übergeben werden und bei Änderungen des Elements aufgerufen werden.

function CallbackRefExample() {
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  
  const measureElement = (node: HTMLDivElement | null) => {
    if (node) {
      const rect = node.getBoundingClientRect();
      setDimensions({ width: rect.width, height: rect.height });
    }
  };
  
  return <div ref={measureElement}>Größe: {dimensions.width}×{dimensions.height}</div>;
}

Callback-Refs werden bei jedem Mount/Remount des Elements aufgerufen – einmal mit dem Element, einmal mit null beim Unmount. Das ist besonders nützlich bei dynamischen Listen oder wenn wir auf das Mounting/Unmounting reagieren müssen.

function DynamicList({ items }: { items: string[] }) {
  const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
  
  const setRef = (id: string) => (element: HTMLLIElement | null) => {
    if (element) {
      elementRefs.current.set(id, element);
    } else {
      elementRefs.current.delete(id);
    }
  };
  
  return (
    <ul>
      {items.map(item => (
        <li key={item} ref={setRef(item)}>{item}</li>
      ))}
    </ul>
  );
}

20.7 Typische Fehler und ihre Vermeidung

Der häufigste Fehler ist das Vergessen von .current. Die Ref ist ein Container, der Wert steckt in current.

// Falsch: Direkter Zugriff auf Ref-Objekt
const ref = useRef(0);
console.log(ref);  // { current: 0 } - das Objekt, nicht der Wert!

// Richtig: Zugriff auf current
console.log(ref.current);  // 0 - der tatsächliche Wert

Ein weiterer Stolperstein ist die Erwartung von Reaktivität. Entwickler, die von useState kommen, sind überrascht, dass Ref-Änderungen keine UI-Updates auslösen.

function NoRerendering() {
  const countRef = useRef(0);
  
  function increment() {
    countRef.current += 1;
    // UI zeigt immer noch 0! Kein Re-Rendering
  }
  
  return (
    <div>
      {countRef.current}
      <button onClick={increment}>+</button>
    </div>
  );
}

Die Lösung: Wenn der Wert im UI sichtbar sein soll, verwende useState. Refs sind für Werte gedacht, die das UI nicht direkt beeinflussen.

Bei DOM-Refs ist der defensive Umgang mit null essentiell. TypeScript hilft mit dem | null Union-Type, aber die Logik muss die Null-Checks durchführen.

function SafeDOMAccess() {
  const inputRef = useRef<HTMLInputElement>(null);
  
  function focusInput() {
    // Optional Chaining
    inputRef.current?.focus();
    
    // Oder expliziter Check
    if (inputRef.current) {
      inputRef.current.focus();
    }
  }
  
  return <input ref={inputRef} />;
}

Ein subtilerer Fehler: Refs in Dependencies von useEffect, useCallback oder useMemo. Das Ref-Objekt selbst ändert sich nie, also triggern diese Dependencies nie. Wenn der Wert in current relevant ist, muss er separat getrackt werden – etwa mit State.

function RefInDependencies() {
  const countRef = useRef(0);
  
  // Problematisch: Ref-Objekt ändert sich nie
  useEffect(() => {
    console.log(countRef.current);
  }, [countRef]);  // Läuft nur einmal beim Mount
  
  // Wenn wir auf Änderungen reagieren wollen, brauchen wir State
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    console.log(count);
  }, [count]);  // Läuft bei jeder Änderung von count
}

20.8 Praktische Patterns aus dem Entwickleralltag

Refs und Effects spielen häufig zusammen. Ein bewährtes Pattern ist das Speichern von Cleanup-Ressourcen in Refs.

function WebSocketConnection({ url }: { url: string }) {
  const wsRef = useRef<WebSocket | null>(null);
  
  useEffect(() => {
    wsRef.current = new WebSocket(url);
    
    wsRef.current.onmessage = (event) => {
      console.log('Nachricht:', event.data);
    };
    
    return () => {
      wsRef.current?.close();
    };
  }, [url]);
  
  function sendMessage(message: string) {
    wsRef.current?.send(message);
  }
  
  return <button onClick={() => sendMessage('Hallo')}>Senden</button>;
}

Für komplexe Initialisierungen, die nur einmal laufen sollen, kombinieren wir Refs mit einem Flag:

function ExpensiveInitialization() {
  const isInitialized = useRef(false);
  
  if (!isInitialized.current) {
    // Läuft nur einmal, auch bei Strict Mode
    performExpensiveSetup();
    isInitialized.current = true;
  }
  
  return <div>Initialisiert</div>;
}

Für Event-Handler, die auf aktuelle Werte zugreifen müssen, ohne bei jeder Änderung neu erstellt zu werden:

function StableHandler() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  
  useEffect(() => {
    countRef.current = count;
  }, [count]);
  
  const handleClick = useCallback(() => {
    // Zugriff auf aktuellen count ohne handleClick neu zu erstellen
    console.log('Count ist:', countRef.current);
  }, []);  // Leeres Array: Funktion wird nie neu erstellt
  
  return <button onClick={handleClick}>Log Count</button>;
}

Refs sind das Schweizer Taschenmesser für Situationen, in denen React’s deklaratives Paradigma an seine Grenzen stößt. Sie öffnen die Tür zur imperativen Welt – aber mit Bedacht eingesetzt, ohne die Vorteile von React’s reaktivem System zu untergraben.