24 useId – Stabile Identifikatoren für Accessibility

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.

24.1 Das Hydration-Dilemma

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.

24.2 Die Lösung: Deterministische ID-Generierung

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.

24.3 Typische Anwendungsmuster

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

24.4 Wiederverwendung und Komposition

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.

24.5 TypeScript-Integration

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.

24.6 Was useId nicht ist

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

24.7 Praktische Integration in Form-Libraries

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.

24.8 Grenzfälle und Besonderheiten

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.