Stabile IDs für Accessibility-Attribute
Barrierefreiheit in React-Komponenten scheitert oft an einem Detail: IDs für aria-labelledby und htmlFor müssen einmalig sein und auf Server und Client übereinstimmen. useId löst beides auf einmal – ohne Boilerplate und ohne Hydration-Fehler.
Inhaltsverzeichnis
- 1. Das ID-Problem in React-Komponenten
- 2. Warum ARIA-IDs einmalig sein müssen
- 3. useId: Syntax und grundlegendes Verhalten
- 4. useId in Formular-Komponenten
- 5. Mehrere IDs aus einem useId-Aufruf
- 6. SSR und Hydration: Warum useId sicher ist
- 7. useId vs. andere ID-Ansätze im Vergleich
- 8. Typische Fehler und Einschränkungen
- 9. TypeScript und Komponentendesign
- 10. Zusammenfassung
- 11. FAQ
1. Das ID-Problem in React-Komponenten
In HTML verknüpfen IDs Elemente miteinander: Ein <label>-Element referenziert ein <input>-Element via htmlFor und id. ARIA-Attribute wie aria-labelledby, aria-describedby und aria-controls referenzieren andere Elemente über ihre IDs. Diese Verknüpfungen sind fundamental für Barrierefreiheit – Screen-Reader navigieren damit durch Formulare und verstehen, welche Beschriftung zu welchem Eingabefeld gehört.
In React entsteht damit ein Problem: Wenn dieselbe Komponente mehrfach gerendert wird, würden alle Instanzen dieselbe statische ID haben. Ein label mit htmlFor="email" und ein input mit id="email" – bei zwei Instanzen der Komponente gibt es vier Elemente mit doppelten IDs. Browser-Verhalten bei doppelten IDs ist undefiniert: Manche Browser nehmen das erste Element, manche das letzte, manche ignorieren die Verknüpfung. Screenreader-Verhalten ist noch unvorhersehbarer.
Das klassische "Lösung" war ein globaler Zähler (let idCounter = 0; const nextId = () => \`id-\${idCounter++}\`) oder das Übergeben von IDs als Props. Beide Ansätze haben Probleme: Ein globaler Zähler ist nicht SSR-sicher – auf dem Server wird bei jedem Request neu gerendert, aber der Zähler akkumuliert weiterhin. Das führt zu Hydration-Mismatches, weil Server-IDs und Client-IDs nicht übereinstimmen. Props-Übergabe ist Boilerplate und schiebt die Verantwortung auf den Aufrufer. useId löst beide Probleme nativ.
2. Warum ARIA-IDs einmalig sein müssen
Die ARIA-Spezifikation definiert, dass IDs, die in ARIA-Attributen referenziert werden, im gesamten Dokument einmalig sein müssen. Das ist keine Empfehlung, sondern eine technische Anforderung: Der Accessibility-Tree, den Screenreader und andere assistive Technologien verwenden, wird vom Browser aus dem DOM aufgebaut. Wenn eine ID mehrfach im Dokument vorkommt, ist unklar, auf welches Element sich die Referenz bezieht.
Betroffen sind alle Attribute, die ID-Referenzen verwenden: aria-labelledby (Beschriftung eines Elements), aria-describedby (Beschreibung oder Hinweis), aria-controls (gesteuerte Elemente, z.B. Accordion-Panel), aria-owns (Besitz von Elementen, die DOM-strukturell an anderer Stelle stehen) und htmlFor (Label-zu-Input-Verknüpfung). Alle diese Referenzen setzen einmalige IDs voraus. In einer React-Anwendung mit wiederverwendbaren Komponenten ist das ohne systematische Lösung kaum korrekt umzusetzen.
Ein konkretes Szenario: Eine Design-System-Komponente FormField kapselt Label, Input und Fehlermeldung. Die Fehlermeldung soll via aria-describedby mit dem Input verknüpft sein. Wenn FormField fünfmal in einem Formular verwendet wird, muss jede Instanz ihre eigene, einmalige ID für den Input und die Fehlermeldung haben. useId erzeugt diese IDs automatisch und korrekt.
3. useId: Syntax und grundlegendes Verhalten
useId hat keine Parameter und gibt einen String zurück. Dieser String ist für jede Komponenten-Instanz einmalig, stabil über Re-Renders und stimmt zwischen Server- und Client-Rendering überein. Das Format der generierten ID ist implementierungsabhängig (z.B. :r0:, :r1: usw.) und sollte nicht geparst oder vorausgesagt werden. Wichtig: Die generierten IDs sind für HTML-Attribute gedacht, nicht als Keys in Listen (key-Prop) oder als Datenbankschlüssel.
Der Hook verwendet intern den Positions-Index der Komponente im Rendertree, kombiniert mit dem React-Root-Index (bei mehreren React-Roots auf einer Seite). Das ergibt deterministische IDs, die auf Server und Client übereinstimmen – die Grundvoraussetzung für Hydration ohne Mismatch. Bei Concurrent Mode und streaming SSR funktioniert useId korrekt, weil die ID-Generierung an die Komponenten-Position im Tree gebunden ist, nicht an den Zeitpunkt des Renderings.
import { useId } from 'react';
// Basic usage — one useId call per component instance
function EmailField() {
// Unique, stable ID for this specific instance
const id = useId();
return (
<div>
{/* htmlFor connects label to input via shared id */}
<label htmlFor={id}>E-Mail-Adresse</label>
<input
id={id}
type="email"
name="email"
autoComplete="email"
aria-required="true"
/>
</div>
);
}
// Multiple instances get different IDs automatically
export function NewsletterForm() {
return (
<form>
{/* Each EmailField instance gets its own unique ID */}
<EmailField />
{/* A second instance would get a different ID — no collision */}
</form>
);
}
// Prefix pattern — derive multiple related IDs from one useId call
function SearchField() {
const baseId = useId();
const inputId = `${baseId}-input`;
const hintId = `${baseId}-hint`;
const errorId = `${baseId}-error`;
return (
<div>
<label htmlFor={inputId}>Suche</label>
<input
id={inputId}
type="search"
aria-describedby={`${hintId} ${errorId}`}
/>
<p id={hintId} className="text-sm text-slate-500">
Mindestens 3 Zeichen eingeben
</p>
<p id={errorId} className="text-sm text-red-600" role="alert">
{/* Error message rendered here when validation fails */}
</p>
</div>
);
}
4. useId in Formular-Komponenten
Formular-Komponenten sind der häufigste Anwendungsfall für useId. Eine vollständige Formularfeld-Komponente besteht typischerweise aus einem Label, einem Input, einem optionalen Hint-Text und einer Fehlermeldung. Für eine korrekte Accessibility-Implementierung müssen Label und Input über htmlFor/id verknüpft sein, und der Input muss über aria-describedby auf Hint und Fehlermeldung verweisen. Das sind mindestens zwei verschiedene IDs pro Komponenten-Instanz.
Das empfohlene Muster: Ein einziger useId-Aufruf pro Komponente, und dann werden alle benötigten IDs durch Anhängen von Suffixen abgeleitet. const id = useId(); const inputId = \`\${id}-input\`; const errorId = \`\${id}-error\`. Dieses Muster funktioniert zuverlässig, weil die Basis-ID einmalig ist und alle abgeleiteten IDs damit ebenfalls einmalig sind. Es ist auch wartungsfreundlich: Wenn neue Elemente mit IDs hinzukommen, wird einfach ein weiteres Suffix abgeleitet.
Eine wichtige Nuance bei aria-describedby: Das Attribut akzeptiert eine durch Leerzeichen getrennte Liste von IDs. Das erlaubt es, sowohl den Hint-Text als auch die Fehlermeldung gleichzeitig zu referenzieren: aria-describedby={\`\${hintId} \${errorId}\`}. Screen-Reader lesen beide Texte vor – zuerst den Hint, dann den Fehler. Das ist ein wichtiges Muster für zugängliche Formulare, das ohne korrekte, einmalige IDs nicht funktioniert.
5. Mehrere IDs aus einem useId-Aufruf
Das Suffix-Muster, bei dem alle IDs einer Komponente von einer einzigen Basis-ID abgeleitet werden, ist die empfohlene Vorgehensweise. Es hat einen praktischen Vorteil gegenüber mehreren useId-Aufrufen: Alle IDs einer Komponente teilen denselben Basis-Prefix, was beim Debugging im DOM sofort sichtbar macht, welche Elemente zusammengehören. Wenn man im DOM :r3:-input, :r3:-hint und :r3:-error sieht, ist klar, dass sie aus derselben Komponenten-Instanz stammen.
Mehrere useId-Aufrufe in derselben Komponente sind technisch möglich und korrekt – jeder Aufruf erzeugt eine neue, einmalige ID. Aber sie produzieren IDs ohne offensichtliche Beziehung zueinander. Für Debugging und Code-Lesbarkeit ist das Suffix-Muster die bessere Wahl. Der einzige Fall, in dem mehrere useId-Aufrufe sinnvoll sind, ist wenn eine Komponente konzeptionell unabhängige Teile enthält, die keine Beziehung zueinander haben und jeweils ihren eigenen ID-Namespace benötigen.
Bei der Verwendung von useId in Custom-Hook-Bibliotheken sollte der Hook in den Custom Hook selbst verschoben werden, nicht in die Komponente, die den Custom Hook verwendet. Das kapseliert die ID-Generierung vollständig im Hook und macht die Komponente, die den Hook aufruft, einfacher. Ein Custom Hook useFormField könnte intern useId aufrufen und nach außen die fertig formattierten IDs und Props zurückgeben.
import { useId } from 'react';
interface FormFieldProps {
label: string;
hint?: string;
error?: string;
type?: string;
name: string;
required?: boolean;
}
// Encapsulates all ID logic — caller never manages IDs
function FormField({
label,
hint,
error,
type = 'text',
name,
required = false,
}: FormFieldProps) {
// Single useId call — all related IDs derived from one base
const baseId = useId();
const inputId = `${baseId}-input`;
const hintId = `${baseId}-hint`;
const errorId = `${baseId}-error`;
// Build aria-describedby from available descriptors
const describedBy = [hint && hintId, error && errorId]
.filter(Boolean)
.join(' ');
return (
<div className="space-y-1">
<label htmlFor={inputId} className="block text-sm font-medium text-slate-700">
{label}
{required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
</label>
<input
id={inputId}
type={type}
name={name}
required={required}
aria-required={required}
aria-describedby={describedBy || undefined}
aria-invalid={error ? 'true' : undefined}
className={`w-full rounded-lg border px-3 py-2 ${
error ? 'border-red-400 bg-red-50' : 'border-slate-300'
}`}
/>
{hint && (
<p id={hintId} className="text-xs text-slate-500">
{hint}
</p>
)}
{error && (
<p id={errorId} className="text-xs text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}
// Usage — no ID management needed by the caller
export function RegistrationForm() {
return (
<form className="space-y-4">
{/* Each instance gets unique IDs automatically */}
<FormField name="email" label="E-Mail" type="email" required hint="Wir senden keine Werbung" />
<FormField name="username" label="Benutzername" required error="Benutzername bereits vergeben" />
<FormField name="password" label="Passwort" type="password" required hint="Mindestens 12 Zeichen" />
</form>
);
}
6. SSR und Hydration: Warum useId sicher ist
Das fundamentale Problem bei IDs in SSR-Anwendungen ist der Hydration-Mismatch. Wenn der Server HTML mit bestimmten IDs rendert und der Client beim Hydration-Prozess andere IDs generiert, gibt es einen Mismatch. React warnt dann in der Konsole und versucht, das DOM zu reparieren – was zu visuellen Glitches führen kann. Ein globaler ID-Zähler ist SSR-unsicher, weil der Zählerstand auf Server und Client unterschiedlich sein kann (auf dem Client wurden z.B. schon andere Komponenten initialisiert).
useId löst dieses Problem durch eine deterministische Generierung, die auf der Position der Komponente im Render-Tree basiert. Da die Render-Tree-Position auf Server und Client identisch ist (gleicher Komponenten-Code, gleiche Props), sind die generierten IDs ebenfalls identisch. React kann den Server-HTML-Snapshot mit dem Client-Render vergleichen und findet übereinstimmende IDs – keine Mismatch, kein DOM-Repair, kein visueller Glitch.
Bei React-Anwendungen mit mehreren React-Roots (z.B. wenn React in eine bestehende Seite eingebettet wird) wird der Root-Index in die ID-Generierung einbezogen. Das stellt sicher, dass zwei React-Roots auf derselben Seite keine ID-Kollisionen haben. Das identifierPrefix-Attribut der createRoot-Funktion ermöglicht außerdem, einen manuellen Prefix zu setzen – nützlich wenn auf einer Seite mehrere React-Apps laufen und IDs nicht kollidieren dürfen.
7. useId vs. andere ID-Ansätze im Vergleich
Bevor useId existierte, haben Entwickler verschiedene Workarounds eingesetzt. Jeder hat spezifische Schwächen, die useId elegant löst.
| Ansatz | SSR-sicher | Concurrent Mode | Kein Boilerplate |
|---|---|---|---|
| Globaler Zähler | Nein — Mismatch | Nein | Ja |
| useState + useEffect | Nein — Mismatch | Nein | Nein — viel Code |
| ID als Prop | Ja | Ja | Nein — Prop-Drilling |
| Math.random() | Nein — Mismatch | Nein | Ja |
| useId | Ja — deterministisch | Ja | Ja — ein Hook-Aufruf |
Der useState+useEffect-Ansatz war ein weit verbreiteter Workaround: Auf dem Server gibt useState('') eine leere ID zurück, und useEffect setzt die ID clientseitig. Das verhindert den Mismatch, aber bedeutet, dass Labels und Inputs auf dem Server ohne Verknüpfung sind. Screen-Reader sehen das initiale Server-HTML ohne ID-Verknüpfungen – was für SEO mit Crawlern, die JavaScript nicht ausführen, problematisch ist. useId hat dieses Problem nicht: Die IDs sind bereits im Server-HTML korrekt gesetzt.
8. Typische Fehler und Einschränkungen
Der häufigste Fehler beim Einsatz von useId: Die generierte ID als Key für Listen-Elemente verwenden. Die React-Dokumentation ist hier eindeutig: useId ist nicht für key-Props gedacht. Keys in Listen sollten aus den Daten selbst kommen (z.B. Datenbankschlüssel, UUIDs aus der API). Eine generierte ID wäre bei jedem Render neu und würde das Reconciliation-Verhalten von React verfälschen.
Ein zweiter häufiger Fehler: useId außerhalb von Komponenten oder Custom Hooks aufrufen. Wie alle React-Hooks darf useId nur in Komponenten oder Custom Hooks aufgerufen werden – nicht in Event-Handlern, nicht in useMemo- oder useCallback-Callbacks, nicht außerhalb des Renderings. Diese Einschränkung gilt für alle Hooks und wird von React-Lint-Regeln überprüft.
Ein dritter Fehler betrifft die Annahme, das Format der generierten ID sei stabil. Das Format kann sich zwischen React-Versionen ändern. Wer das Format in Tests, CSS-Selektoren oder JavaScript-Queries voraussetzt, schreibt fragilen Code. Die ID sollte nur als Wert für HTML-Attribute verwendet werden, nie als Query-Selector oder für andere Zwecke. Für Testing empfiehlt sich, Elemente über zugängliche Eigenschaften zu finden (z.B. Rolle, Label-Text) statt über IDs.
import { useId } from 'react';
// WRONG: useId used as list key — IDs are not stable across renders for list reconciliation
function WrongList({ items }: { items: string[] }) {
const id = useId();
return (
<ul>
{items.map((item, index) => (
// WRONG: Don't use useId for list keys — use item data or index
<li key={`${id}-${index}`}>{item}</li>
))}
</ul>
);
}
// RIGHT: List keys from data, useId only for ARIA attributes
function RightList({ items }: { items: { id: string; label: string }[] }) {
const listId = useId();
return (
<ul id={listId} role="listbox" aria-label="Auswahloptionen">
{items.map((item) => (
// Data-driven key — not related to useId
<li key={item.id} role="option" aria-selected={false}>
{item.label}
</li>
))}
</ul>
);
}
// RIGHT: Custom hook encapsulates ID logic
function useFormField(name: string) {
const baseId = useId();
return {
inputProps: {
id: `${baseId}-input`,
name,
'aria-describedby': `${baseId}-hint ${baseId}-error`,
},
labelProps: { htmlFor: `${baseId}-input` },
hintProps: { id: `${baseId}-hint` },
errorProps: { id: `${baseId}-error`, role: 'alert' as const },
};
}
// Clean component — no manual ID management
function PasswordField() {
const field = useFormField('password');
return (
<div>
<label {...field.labelProps}>Passwort</label>
<input type="password" {...field.inputProps} />
<p {...field.hintProps}>Mindestens 12 Zeichen</p>
<p {...field.errorProps}>{/* error message */}</p>
</div>
);
}
9. TypeScript und Komponentendesign
useId gibt immer einen string zurück und hat keine Typparameter. Die TypeScript-Integration ist trivial, aber es gibt wichtige Designentscheidungen bei der Verwendung in Komponenten-Bibliotheken. Eine gut designte Komponente, die useId intern verwendet, sollte keine id-Prop nach außen exponieren müssen – die ID-Generierung ist ein Implementierungsdetail der Komponente. Wenn eine externe ID-Prop trotzdem nötig ist (z.B. für e2e-Tests oder CSS), sollte sie optional sein und die intern generierte ID überschreiben können.
Das Custom-Hook-Pattern ist besonders wertvoll für Design-System-Bibliotheken. Ein useFormField-Hook, der intern useId aufruft und nach außen fertige Props-Objekte zurückgibt, macht Formular-Komponenten maximal einfach zu verwenden. Der Aufrufer verteilt die Props-Objekte via Spread-Operator auf die entsprechenden Elemente, ohne je eine ID zu sehen oder zu managen. Das ist das sauberste API-Design für zugängliche Formular-Komponenten.
Bei Tests mit React Testing Library ist es wichtig, Elemente nicht über generierte IDs zu finden. getByRole('textbox', { name: 'E-Mail-Adresse' }) ist robuster und zugänglicher als getById, weil es die korrekte Label-Verknüpfung testet. Ein Test, der erfolgreich getByRole mit einem Label-Text findet, stellt sicher, dass die htmlFor/id-Verknüpfung korrekt ist – genau das, was useId ermöglicht.
10. Zusammenfassung
useId ist ein kleiner Hook mit großer Wirkung auf Barrierefreiheit. Er erzeugt einmalige, stabile IDs für jede Komponenten-Instanz – deterministisch auf Server und Client, ohne globale Zähler, ohne Props, ohne useEffect. Das macht korrekte ARIA-Verknüpfungen in wiederverwendbaren Komponenten zu einer trivialen Aufgabe statt eines Boilerplate-Problems.
Das empfohlene Muster: Ein useId-Aufruf pro Komponente (oder Custom Hook), alle benötigten IDs durch Suffix-Ableitung. Nicht für Listen-Keys verwenden. Das Format der ID nicht voraussetzen. Den Hook idealerweise in einem Custom Hook kapseln, der fertige Props-Objekte zurückgibt. Und in Tests Elemente über Rollen und Label-Texte finden statt über IDs. Wer diese vier Punkte beachtet, implementiert zugängliche React-Komponenten, die korrekt in SSR-Umgebungen funktionieren und bei mehrfacher Verwendung nie ID-Kollisionen haben.
useId — Das Wichtigste auf einen Blick
Wofür
Einmalige IDs für aria-labelledby, aria-describedby, aria-controls und htmlFor – korrektes ARIA ohne ID-Kollisionen bei mehrfach verwendeten Komponenten.
SSR-sicher
Deterministisch basierend auf der Position im Render-Tree – Server und Client generieren identische IDs, kein Hydration-Mismatch.
Suffix-Muster
Einen useId-Aufruf pro Komponente, alle IDs als Suffixe ableiten: ${baseId}-input, ${baseId}-hint, ${baseId}-error.
Nicht für List-Keys
useId ist nicht für key-Props in Listen gedacht. Keys sollten aus Daten stammen. useId ist ausschließlich für HTML-Attribute gedacht.