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.
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.
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} />;
}
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>;
}
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.
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.
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>
);
}
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
}
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.