Skip to main content
useId generates a unique ID that is stable across server and client renders. This is useful for accessibility attributes like aria-describedby that require matching IDs between elements.

Signature

function useId(): string

Parameters

This hook takes no parameters.

Returns

Returns a unique string ID that:
  • Is stable across renders of the same component instance
  • Is unique within the application
  • Is consistent between server-side and client-side rendering
  • Follows the format P{rootId}-{incrementId}

Basic Usage

import { useId } from 'preact/hooks';

function TextField({ label }) {
  const id = useId();

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" />
    </div>
  );
}

function Form() {
  return (
    <form>
      <TextField label="Name" />
      <TextField label="Email" />
    </form>
  );
}

Accessibility with ARIA

function Tooltip({ text, children }) {
  const id = useId();

  return (
    <>
      <span aria-describedby={id}>{children}</span>
      <div id={id} role="tooltip" className="tooltip">
        {text}
      </div>
    </>
  );
}

function App() {
  return (
    <div>
      <Tooltip text="This is your username">
        <input type="text" placeholder="Username" />
      </Tooltip>
      <Tooltip text="Must be at least 8 characters">
        <input type="password" placeholder="Password" />
      </Tooltip>
    </div>
  );
}
function FormField({ label, hint, error }) {
  const id = useId();
  const hintId = `${id}-hint`;
  const errorId = `${id}-error`;

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        type="text"
        aria-describedby={`${hintId}${error ? ` ${errorId}` : ''}`}
        aria-invalid={!!error}
      />
      {hint && <small id={hintId}>{hint}</small>}
      {error && <span id={errorId} role="alert">{error}</span>}
    </div>
  );
}

function App() {
  return (
    <FormField
      label="Email"
      hint="We'll never share your email"
      error="Please enter a valid email"
    />
  );
}

Radio Button Group

function RadioGroup({ label, options, name, value, onChange }) {
  const groupId = useId();
  const labelId = `${groupId}-label`;

  return (
    <div role="radiogroup" aria-labelledby={labelId}>
      <div id={labelId}>{label}</div>
      {options.map((option) => {
        const optionId = `${groupId}-${option.value}`;
        return (
          <div key={option.value}>
            <input
              id={optionId}
              type="radio"
              name={name}
              value={option.value}
              checked={value === option.value}
              onChange={onChange}
            />
            <label htmlFor={optionId}>{option.label}</label>
          </div>
        );
      })}
    </div>
  );
}

function App() {
  const [size, setSize] = useState('medium');

  return (
    <RadioGroup
      label="Select size"
      name="size"
      value={size}
      onChange={(e) => setSize(e.target.value)}
      options={[
        { value: 'small', label: 'Small' },
        { value: 'medium', label: 'Medium' },
        { value: 'large', label: 'Large' }
      ]}
    />
  );
}

Combobox with Listbox

function Combobox({ label, options }) {
  const [isOpen, setIsOpen] = useState(false);
  const [selected, setSelected] = useState(null);
  const id = useId();
  const listboxId = `${id}-listbox`;
  const labelId = `${id}-label`;

  return (
    <div>
      <label id={labelId}>{label}</label>
      <input
        id={id}
        role="combobox"
        aria-labelledby={labelId}
        aria-expanded={isOpen}
        aria-controls={listboxId}
        aria-autocomplete="list"
        value={selected?.label || ''}
        onFocus={() => setIsOpen(true)}
        onBlur={() => setIsOpen(false)}
      />
      {isOpen && (
        <ul id={listboxId} role="listbox" aria-labelledby={labelId}>
          {options.map((option) => {
            const optionId = `${id}-option-${option.value}`;
            return (
              <li
                key={option.value}
                id={optionId}
                role="option"
                aria-selected={selected?.value === option.value}
                onClick={() => setSelected(option)}
              >
                {option.label}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

Accordion with Multiple Panels

function AccordionPanel({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  const id = useId();
  const buttonId = `${id}-button`;
  const panelId = `${id}-panel`;

  return (
    <div>
      <h3>
        <button
          id={buttonId}
          aria-expanded={isOpen}
          aria-controls={panelId}
          onClick={() => setIsOpen(!isOpen)}
        >
          {title}
        </button>
      </h3>
      <div
        id={panelId}
        role="region"
        aria-labelledby={buttonId}
        hidden={!isOpen}
      >
        {children}
      </div>
    </div>
  );
}

function Accordion() {
  return (
    <div>
      <AccordionPanel title="Panel 1">
        <p>Content for panel 1</p>
      </AccordionPanel>
      <AccordionPanel title="Panel 2">
        <p>Content for panel 2</p>
      </AccordionPanel>
      <AccordionPanel title="Panel 3">
        <p>Content for panel 3</p>
      </AccordionPanel>
    </div>
  );
}

Dialog with Description

function Dialog({ title, description, children, isOpen, onClose }) {
  const id = useId();
  const titleId = `${id}-title`;
  const descId = `${id}-description`;

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-labelledby={titleId}
      aria-describedby={descId}
      aria-modal="true"
    >
      <h2 id={titleId}>{title}</h2>
      <p id={descId}>{description}</p>
      <div>{children}</div>
      <button onClick={onClose}>Close</button>
    </div>
  );
}

function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Open Dialog</button>
      <Dialog
        title="Confirm Action"
        description="Are you sure you want to proceed with this action?"
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
      >
        <button>Confirm</button>
        <button onClick={() => setIsOpen(false)}>Cancel</button>
      </Dialog>
    </div>
  );
}

Tab Panel

function TabPanel({ tabs }) {
  const [activeTab, setActiveTab] = useState(0);
  const baseId = useId();

  return (
    <div>
      <div role="tablist">
        {tabs.map((tab, index) => {
          const tabId = `${baseId}-tab-${index}`;
          const panelId = `${baseId}-panel-${index}`;
          return (
            <button
              key={index}
              id={tabId}
              role="tab"
              aria-selected={activeTab === index}
              aria-controls={panelId}
              onClick={() => setActiveTab(index)}
            >
              {tab.label}
            </button>
          );
        })}
      </div>
      {tabs.map((tab, index) => {
        const tabId = `${baseId}-tab-${index}`;
        const panelId = `${baseId}-panel-${index}`;
        return (
          <div
            key={index}
            id={panelId}
            role="tabpanel"
            aria-labelledby={tabId}
            hidden={activeTab !== index}
          >
            {tab.content}
          </div>
        );
      })}
    </div>
  );
}
useId is especially important for server-side rendering (SSR). It ensures that IDs match between the server-rendered HTML and the client-side hydrated version, preventing hydration mismatches.
Don’t use useId to generate keys for list items. Keys should be based on your data, not generated IDs.
Each call to useId generates a new unique ID. If you need multiple related IDs, call useId once and derive the other IDs by appending suffixes like ${id}-label, ${id}-input, etc.

Build docs developers (and LLMs) love