React's useId hook solved the form accessibility problem I'd been hacking around
I was building a reusable form component and hit a familiar problem: I needed to connect a label to an input with a unique ID, but generating that ID was a mess — incrementing counters that broke SSR, Math.random() that caused hydration mismatches, prop drilling IDs from parent components. Then I found useId in React 18 and realized this was exactly what I had been solving wrong for years.
The problem useId solves
For accessibility, every form input needs a unique id attribute, and its label needs a matching htmlFor. When you have a reusable component that renders multiple instances on the same page, you need those IDs to be unique across instances — and they need to be stable across server and client renders.
// The old way — causes hydration mismatch warnings
let counter = 0;
function TextField({ label }: { label: string }) {
const id = `field-${counter++}`; // Different on server vs client!
return (
);
}
// The useId way — stable, unique, SSR-safe
import { useId } from 'react';
function TextField({ label }: { label: string }) {
const id = useId(); // Generates ':r0:', ':r1:', etc.
return (
);
}
Building a complete accessible form field
import { useId } from 'react';
interface FormFieldProps {
label: string;
type?: 'text' | 'email' | 'password' | 'number';
error?: string;
hint?: string;
required?: boolean;
value: string;
onChange: (value: string) => void;
}
export function FormField({
label, type = 'text', error, hint, required,
value, onChange,
}: FormFieldProps) {
const id = useId();
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
// Build aria-describedby from whichever elements exist
const describedBy = [
hint && hintId,
error && errorId,
].filter(Boolean).join(' ') || undefined;
return (
onChange(e.target.value)}
aria-describedby={describedBy}
aria-invalid={error ? 'true' : undefined}
aria-required={required}
/>
{hint && (
{hint}
)}
{error && (
{error}
)}
);
}
// Usage — each instance gets unique IDs automatically
function ContactForm() {
return (
);
}
Using useId for ARIA relationships
useId is useful anywhere you need to connect elements via ID:
// Accordion with accessible aria-controls
function AccordionItem({ title, children }: { title: string; children: React.ReactNode }) {
const contentId = useId();
const [isOpen, setIsOpen] = useState(false);
return (
{children}
);
}
// Combobox with accessible listbox
function SearchInput() {
const listboxId = useId();
const [query, setQuery] = useState('');
const results = useSearch(query);
return (
0}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{results.map((r) => (
- {r.label}
))}
);
}
What useId does NOT do
useId is for accessibility and ARIA relationships only. Do not use it for:
- List keys — use your data's actual ID
- Database record IDs
- Any ID you need to persist beyond the component's lifetime
The generated IDs like :r0: are intentionally unguessable and will change between renders if the component tree structure changes. They are stable within a render, not across renders or deploys.
Before useId existed, I maintained a module-level counter and warned everyone in the README not to use the component in SSR. useId removed an entire category of bug from reusable form components. It is one of the most practically useful additions in React 18.