Що таке hook useId у React?
useId - це хук React 18, який генерує стабільний унікальний ID, що гарантовано збігається на сервері та клієнті.
Теорія
TL;DR
useIdстворює ID на кшталт:r0:,:r1:через внутрішній лічильник React, без random- Головна відмінність від
Math.random(): сервер і клієнт завжди дають однакове значення - Використовуй для
htmlFor/idпар таaria-*атрибутів у SSR-застосунках; для чисто клієнтського коду не потрібен - Ніколи не викликай всередині
.map()- виклич один раз у компоненті, потім дописуй стабільні дані
Швидкий приклад
import { useId } from 'react';
function EmailInput() {
const id = useId(); // Той самий ":r0:" на сервері та клієнті
return (
<>
<label htmlFor={id}>Email</label>
<input id={id} type="email" />
</>
);
}Один виклик, один стабільний ID. Сервер рендерить :r0:, браузер гідратує з :r0:. Жодних попереджень.
Чому випадкові ID ламають SSR
React рендерить компонент на сервері й видає HTML. Браузер отримує цей HTML і React гідратує його - прив'язує обробники подій і звіряє virtual DOM. Якщо будь-який атрибут відрізняється між серверним HTML і тим, що React генерує на клієнті, з'являється попередження про hydration mismatch.
Math.random() дає "0.123..." на сервері і "0.456..." на клієнті. Різні значення ламають зв'язок htmlFor/id. Screen reader втрачає асоціацію між label та input. useId уникає цього, бо лічильник React виконує ту саму послідовність на обох сторонах.
Як це працює
React тримає глобальний лічильник на кожен корінь дерева компонентів. Під час SSR він збільшує лічильник при рендері кожного компонента й серіалізує стан. На клієнті hydrateRoot стартує з того самого індексу й відтворює послідовність - тому кожен виклик useId потрапляє на те саме число, що й на сервері.
Обгортання двокрапками (:r1:) не випадкове. Двокрапка має спеціальне значення в CSS (псевдокласи, псевдоелементи), тому ID типу :r0: неможливо випадково зачепити стилями. Ці ID існують для атрибутів доступності, не для CSS.
Коли використовувати
- Пари
htmlFor/idу формах з SSR aria-labelledbyіaria-describedbyв доступних компонентах (тултіпи, модалки, описи)- Кілька ID в одному компоненті - один виклик
useId(), потім суфікси:${id}-email,${id}-password
Для чисто клієнтських застосунків не потрібен - там підійде будь-який унікальний рядок. Також не використовуй для data-testid: просто хардкодь "email-input" у тестах. useId для тестових атрибутів - зайве навантаження без жодної користі.
Типові помилки
Виклик useId всередині .map()
// ❌ Нові слоти при кожному рендері - ID змінюються
{users.map(u => <input key={u.id} id={useId()} />)}
// ✅ Виклич один раз у компоненті, дописуй стабільні дані
function UserInput({ user }) {
const baseId = useId();
return <input id={`${baseId}-${user.id}`} />;
}Кожен useId() всередині циклу відкриває новий слот при кожному рендері. Лічильники сервера та клієнта розходяться.
Використання для ключів списків
// ❌ Неправильно
{items.map(item => <li key={useId()}>...</li>)}
// ✅ Використовуй дані з самого елемента
{items.map(item => <li key={item.id}>...</li>)}Ключі мають бути стабільними між рендерами. useId всередині map дає різні значення щоразу.
Очікування глобальної унікальності в кількох root'ах
ID обмежені одним деревом компонентів. Портали і окремі ReactDOM.createRoot мають власні лічильники, тому :r1: може з'явитись у кількох деревах. Якщо вони спільно використовують DOM - додавай префікс вручну: useId() + '-modal'.
Де зустрічається у продакшені
- Material-UI v6+: внутрішньо використовує
useIdдляaria-describedbyу полях вводу - React Aria (Adobe): всі зв'язки label-форм через
useId - Chakra UI:
aria-labelledbyу модалках - Next.js App Router: серверні компоненти отримують стабільні ID для полів форм без додаткових налаштувань
Питання на співбесіді
Q: Чому ID має двокрапки - :r0:?
A: Щоб уникнути конфліктів із CSS. ID, що починається з :, не можна вибрати звичайним CSS-правилом - і це навмисно. Ці ID для доступності, не для стилізації.
Q: Що відбувається з кількома root'ами або порталами?
A: Кожен root має свій лічильник. Якщо два root'и спільно використовують DOM, їхні ID можуть збігатись. Додавай ручний префікс для уникнення колізій.
Q: Чи ламає React StrictMode роботу useId?
A: Ні. StrictMode подвоює виклики ефектів у розробці, але лічильник ID зберігається для гідратації. Production SSR не зачіпається.
Q: Чи можна задати власний формат ID?
A: Публічного API для цього немає. Якщо потрібні читабельні ID в тестах - використовуй хардкодні data-testid.
Q: (Senior) Як useId взаємодіє з Suspense boundaries у streaming SSR?
A: Кожна Suspense boundary відслідковує лічильник ID незалежно. Коли ліниве піддерево стрімиться, воно підхоплює правильну послідовність. Mismatch виникає тільки при порушенні порядку виклику хуків всередині boundary.
Приклади
Форма входу з парами label-input
import { useId } from 'react';
function LoginForm() {
const id = useId(); // Один базовий ID для всієї форми
return (
<form>
<label htmlFor={`${id}-email`}>Email</label>
<input id={`${id}-email`} type="email" />
<label htmlFor={`${id}-password`}>Пароль</label>
<input id={`${id}-password`} type="password" />
</form>
);
}Один виклик useId дає базу на кшталт :r3:. Обидва поля отримують стабільні ID (:r3:-email, :r3:-password) без другого виклику хука. На практиці такий підхід із суфіксами зручніший, ніж два окремих виклики useId - менше слотів, той самий результат.
Доступний тултіп із aria-describedby
import { useId } from 'react';
function Tooltip({ text, children }: { text: string; children: React.ReactNode }) {
const id = useId();
return (
<div>
<div aria-describedby={id}>{children}</div>
<div role="tooltip" id={id}>{text}</div>
</div>
);
}aria-describedby на тригері та id на тултіпі мають збігатись, щоб допоміжні технології зчитували опис. На сторінці з SSR useId гарантує цей збіг без жодної ручної координації.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.