Barrierefreiheit im Web basiert auf semantischen Verknüpfungen. Ein
Formularfeld muss wissen, welches Label zu ihm gehört. Ein
Fehlermeldungstext muss sich eindeutig einem Input-Element zuordnen
lassen. Screenreader und andere assistive Technologien verlassen sich
auf diese Verbindungen – und die werden über HTML-IDs hergestellt. Der
useId-Hook löst ein Problem, das erst in modernen
React-Anwendungen mit Server-Side Rendering wirklich kritisch wurde: die
zuverlässige Generierung eindeutiger, stabiler Identifikatoren.
Bevor React 18 kam useId einführte, stand jeder
Entwickler vor derselben Frage: Wie generiere ich eine eindeutige ID für
ein Input-Element? Die naheliegenden Lösungen –
Math.random(), uuid-Bibliotheken oder simple
Zähler – funktionieren in klassischen Client-Only-Anwendungen
einwandfrei. Sobald jedoch Server-Side Rendering ins Spiel kommt, bricht
das Konzept zusammen.
Das Problem zeigt sich während der Hydration. Der Server rendert eine
Komponente mit einem Input-Feld und generiert dabei eine ID, sagen wir
input-7a3f. Dieser HTML-Code wird an den Browser geschickt.
React hydratisiert dann die Komponente im Client – und generiert eine
neue ID, diesmal vielleicht input-2b9d. Der HTML-String vom
Server enthält aber noch die alte ID. Das Ergebnis: Label und Input
passen nicht mehr zusammen, ARIA-Verknüpfungen zeigen ins Leere, und die
gesamte Accessibility-Struktur ist zerstört.
// Problematisch mit Math.random()
function EmailField() {
const id = `email-${Math.random().toString(36)}`;
return (
<>
<label htmlFor={id}>E-Mail</label>
<input id={id} type="email" />
</>
);
}
// Server generiert: email-7a3f
// Client generiert: email-2b9d
// → Hydration Mismatch!
Die Alternative wäre, IDs als Props durchzureichen und von außen zu definieren. Das funktioniert, macht aber wiederverwendbare Komponenten erheblich umständlicher und verlagert die Verantwortung auf jeden Komponentenverwender.
useId löst dieses Problem durch einen cleveren
Mechanismus: Die generierte ID basiert nicht auf Zufall, sondern auf der
Position der Komponente im React-Komponentenbaum. Dieselbe Komponente an
derselben Stelle erzeugt sowohl auf dem Server als auch im Client exakt
dieselbe ID.
function EmailField() {
const id = useId();
return (
<>
<label htmlFor={id}>E-Mail</label>
<input id={id} type="email" />
</>
);
}
Die generierte ID sieht typischerweise so aus: :r1:,
:r2: oder :r3:. Das Format ist bewusst
undokumentiert und kann sich zwischen React-Versionen ändern – die IDs
sind ausschließlich für HTML-Attribute gedacht, niemals für
Anwendungslogik oder CSS-Selektoren.
Der Hook nimmt keine Parameter entgegen und hat auch keine
Dependencies. Er ist zustandslos im klassischen Sinne – die ID wird beim
ersten Render der Komponente festgelegt und bleibt dann konstant. Das
macht useId zu einem der performantesten Hooks überhaupt:
kein Re-Rendering, keine Closures, keine Memory-Overhead.
Die Grundverwendung für Label-Input-Verknüpfungen haben wir bereits gesehen. Interessanter wird es bei komplexeren Formularfeldern, die mehrere verwandte Elemente benötigen: das Input-Feld selbst, einen Beschreibungstext, eine Fehlermeldung, vielleicht noch einen Hinweis.
interface FormFieldProps {
label: string;
type?: string;
helpText?: string;
error?: string;
}
function FormField({ label, type = "text", helpText, error }: FormFieldProps) {
const id = useId();
const helpId = `${id}-help`;
const errorId = `${id}-error`;
const describedBy = [
helpText ? helpId : null,
error ? errorId : null
].filter(Boolean).join(" ");
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
type={type}
aria-describedby={describedBy || undefined}
aria-invalid={error ? true : undefined}
/>
{helpText && (
<span id={helpId} className="help-text">
{helpText}
</span>
)}
{error && (
<span id={errorId} className="error-text" role="alert">
{error}
</span>
)}
</div>
);
}
Dieses Pattern – eine Basis-ID generieren und durch Suffixe erweitern
– hat sich bewährt. Die IDs bleiben zusammengehörig und eindeutig.
Wichtig ist, dass nur der Basis-Aufruf useId() verwendet
wird. Mehrere useId()-Aufrufe würden verschiedene, nicht
verwandte IDs erzeugen.
ARIA-Attribute sind der zweite große Einsatzbereich. Die Tabelle zeigt die häufigsten Verknüpfungen:
| ARIA-Attribut | Zweck | Beispiel-Verwendung |
|---|---|---|
aria-labelledby |
Verweist auf Label-Element(e) | Komplexe Form-Widgets mit mehreren Labels |
aria-describedby |
Verweist auf beschreibende Texte | Hilfetexte, Fehlermeldungen, Formathinweise |
aria-controls |
Zeigt an, welches Element kontrolliert wird | Tabs, Accordions, expandierbare Bereiche |
aria-owns |
Definiert Parent-Child-Beziehung | Verschachtelte Listen, Menüstrukturen |
Eine gut designte Formular-Komponente sollte nach außen keine IDs exponieren müssen. Stattdessen kapselt sie die ID-Generierung vollständig und bietet eine einfache API.
interface TextInputProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
}
function TextInput({ label, value, onChange, placeholder, required }: TextInputProps) {
const id = useId();
return (
<div className="form-group">
<label htmlFor={id}>
{label}
{required && <span aria-label="erforderlich">*</span>}
</label>
<input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
aria-required={required}
/>
</div>
);
}
// Verwendung: Keine ID nötig
function RegistrationForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
return (
<form>
<TextInput label="Name" value={name} onChange={setName} required />
<TextInput label="E-Mail" value={email} onChange={setEmail} required />
</form>
);
}
Mehrere Instanzen derselben Komponente auf einer Seite stellen kein Problem dar – jede erhält automatisch eine eigene, eindeutige Basis-ID.
TypeScript benötigt bei useId keine besonderen
Typdeklarationen. Der Hook gibt immer einen string zurück,
und dieser Typ wird automatisch inferiert.
const id = useId(); // Typ: string
In Komponenten-Props sollten ID-Parameter explizit als optional
markiert werden, wenn die Komponente intern useId
verwendet, aber auch externe IDs akzeptieren soll.
interface InputProps {
label: string;
id?: string; // Optional: Externe ID überschreibt useId
}
function Input({ label, id: externalId }: InputProps) {
const generatedId = useId();
const id = externalId ?? generatedId;
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
Dieser Ansatz bietet Flexibilität: Die Komponente funktioniert eigenständig, kann aber bei Bedarf in bestehende ID-Schemas integriert werden.
Ein häufiges Missverständnis betrifft die Verwendung für React Keys. Keys dienen einem völlig anderen Zweck – sie helfen React, Listenelemente zu identifizieren und effizient zu aktualisieren. Keys sollten stabil, vorhersagbar und aus den Daten abgeleitet sein.
// Falsch: useId für Keys
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo) => {
const id = useId(); // ❌ Verletzt Hook-Regeln (in Loop)
return <li key={id}>{todo.text}</li>;
})}
</ul>
);
}
// Richtig: Daten-basierte Keys
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
Selbst wenn man den Hook-Regelverstoß umgehen würde (indem man
useId außerhalb der Map aufruft), wären die generierten IDs
ungeeignet. Sie hängen von der Komponentenposition ab, nicht von den
Daten. Wird ein Element aus der Mitte der Liste entfernt, ändern sich
die Positionen aller nachfolgenden Elemente – und damit ihre IDs.
Ein zweiter Fehler ist der Versuch, useId-Werte in
CSS-Selektoren zu verwenden. Die IDs sind implementierungsdetails von
React und ihre Struktur kann sich ändern.
// Falsch: Styling via useId
function Component() {
const id = useId();
return (
<>
<style>{`
#${id} { color: red; }
`}</style>
<div id={id}>Text</div>
</>
);
}
// Richtig: CSS-Klassen verwenden
function Component() {
const id = useId();
return (
<div id={id} className="styled-component">
Text
</div>
);
}
Moderne Formular-Bibliotheken haben useId längst in ihre
Kern-APIs integriert. Bei React Hook Form beispielsweise generiert das
register-Feature automatisch stabile IDs für jedes
Feld.
import { useForm } from 'react-hook-form';
function ContactForm() {
const { register, handleSubmit } = useForm();
// React Hook Form nutzt intern useId für jedes Field
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<label htmlFor={register('email').name}>E-Mail</label>
<input {...register('email')} type="email" />
<label htmlFor={register('message').name}>Nachricht</label>
<textarea {...register('message')} />
</form>
);
}
Beim Bau eigener Abstractions sollte useId von Anfang an
eingeplant werden. Eine Komponente, die heute nur ein einzelnes
Input-Feld verwaltet, wird morgen vielleicht Fehlervalidierung,
Hilfetexte oder bedingte Anzeigen benötigen – und dann zahlt sich die
saubere ID-Struktur aus.
In Komponenten, die dynamisch erzeugt werden – etwa durch
Array.map – darf useId nicht innerhalb der
Iteration aufgerufen werden. Die Hook-Regeln verbieten das explizit. Die
Lösung liegt darin, jedem Array-Element eine eigene Komponente zu geben,
die dann intern useId nutzt.
// Falsch: useId in Array.map
function FieldList({ fields }: { fields: string[] }) {
return (
<>
{fields.map((field) => {
const id = useId(); // ❌ Verletzt Hook-Regeln
return <input key={field} id={id} />;
})}
</>
);
}
// Richtig: Eigene Komponente pro Element
function Field({ name }: { name: string }) {
const id = useId();
return <input id={id} name={name} />;
}
function FieldList({ fields }: { fields: string[] }) {
return (
<>
{fields.map((field) => (
<Field key={field} name={field} />
))}
</>
);
}
Bei Test-Frameworks kann das undokumentierte ID-Format zu Problemen
führen. Selektoren wie screen.getByLabelText()
funktionieren problemlos, aber direkte ID-Abfragen schlagen fehl, wenn
man die konkrete ID erwartet.
// Robust: Über Label selektieren
const input = screen.getByLabelText('E-Mail');
// Fragil: Über ID selektieren
const input = document.getElementById(':r1:'); // ❌ Format kann sich ändern
Die bessere Test-Strategie fokussiert auf das Verhalten aus Nutzersicht: Kann ein Label geklickt werden und aktiviert es das richtige Feld? Wird ein Hilfetext vom Screenreader vorgelesen? Diese Tests sind robust gegenüber Implementierungsdetails wie der konkreten ID-Struktur.