Всем привет!

Уже давно я заприметил относительно новый хук useId, с которым давно хотел разобраться для чего он нужен, как он работает и конечно же обязательно нужно заглянуть в исходники. И теперь потыкав этот хук палкой, почитав React документацию, пролистав несколько статей и изучив парочку видео на YouTube. Я готов этим с вами поделиться. Поехали! (Данная статья является расшифровкой видео)

Что такое useId( ) ?

И так, что же делает хук useId()? Он возвращает нам уникальный id, который выглядит следующим образом: :r1:, :r2:, :r3: Странность данного формата id думаю и помогает быть ему более уникальным на странице.

Тут сразу много вопросов возникает:

  • Для чего нам вообще нужен такой невнятный id?

  • Почему id нужно генерить именно с помощью React? Что не так с uuid или с любым другим генератором id?

  • Насколько уникальный id генерируется? Он уникален в рамках компонента? в рамках страницы? или в рамках сессии?

  • Останутся ли прежними id после перегрузки страницы?

Как видите вопросов много и сейчас от простого к сложному ответим на все эти вопросы.

Зачем нужен такой невнятный id?

Чтобы ответить на этот вопрос давайте рассмотрим следующий код:

<label>
  <span>Some label</span>
  <input name="some-input" />
</label>

Это хорошая практика при работе с input и label. Основная идея в том, что с помощью тега label мы оборачиваем input и это их связывает между собой. Что значит фраза связывает между собой. Если кликнуть на текст внутри тега label, то мы увидим, что поставился фокус в input. Это очень полезная фича для пользователей. Особенно когда речь идет например о input типа checkbox. Кликнуть мышкой на checkbox иногда бывает достаточно сложно, а вот кликнуть на текст куда проще

Чтобы вы все это пощупали, я подготовил небольшой пример.

Но иногда мы не можем обернуть тэгом label нужный input и кладем его отдельно недалеко от input. Как же в таком случае можно связать label и input? Для этого у тэга label есть атрибут htmlFor (JSX) куда мы можем передать id навешенный на input. И таким образом установить связь между input и label.

<div>
  <label htmlFor="some-id">Some input</label>
  <InfoIcon onClick={onClick} />
  <input name="some-input" id="some-id" />
</div>

Вопрос лишь в том, какое значение передать в id. Нам нужно его выдумать и быть уверенным, что текущее id уникальное на всю страницу. А как мы знаем придумать хорошее имя переменной или в нашем случае для id, одна из самых паскудных задач в программировании. Но если задуматься, нам не зачем придумывать какое-то конкретное значение. Будет достаточно и просто любого несуразного значения.

Именно этим и занимается хук useId. Он придумывает какое-то совсем нелепое значение и гарантирует его уникальность, тем самым освобождая нас сразу от двух задач. Придумывания имени и беспокойства, что это имя уже кем то используется. Согласитесь удобно ведь.

import { useId } from 'react';

const App = () => {
  const id = useId();

  return (
    <div>
      <label htmlFor={id}>Some input</label>
      <InfoIcon onClick={onClick} />
      <input name="some-input" id={id} />
    </div>
  );
};

Усложняем пример

Хорошо базовую идею как это работает думаю мы все поняли. А теперь давайте чуть усложним компонент. В этой форме всего 3 input и для его написания мне понадобилось целых 7 id.

const App = () => {
  const formId = useId();
  const firstNameId = useId();
  const firstNameHintId = useId();
  const lastNameId = useId();
  const lastNameHintId = useId();
  const emailId = useId();
  const emailHintId = useId();

  return (
    <form id={formId} onSubmit={onSubmit}>
      <label htmlFor={firstNameId}>First Name</label>
      <input name="firstName" id={firstNameId} aria-describedby={firstNameHintId} />
      <p id={firstNameHintId} >first name hint</p>

      <label htmlFor={lastNameId}>First Name</label>
      <input name="firstName" id={lastNameId} aria-describedby={lastNameHintId} />
      <p id={lastNameHintId} >first name hint</p>

      <label htmlFor={emailId}>First Name</label>
      <input name="firstName" id={emailId} aria-describedby={emailHintId} />
      <p id={emailHintId} >first name hint</p>
    </form>
    <button type="submit" form={formId}>Submit</button>
  );
};

Сначала я связал 3 label-а и 3 input-а. Это 3 id-шки.

После нам поставили задачу, для улучшения доступности нашей формы. Нужно связать input-ы с текстовым описанием этих input-ов. Это делается с помощью тяга aria-describedby и сюрприз, id-шки HTML тэта где находится текстовое описание этого input-а. т. е. теперь нужно еще столько же id-шек. Уже 6 насобирали.

Последняя id самая интересная. Я всегда для написания форм использую HTML тег form в связке с button type=“submit”. Но периодически так получается, что кнопку нужно расположить вне тэга form. Особенно часто это случается, когда форма находится внутри модального окна. И кнопки там отдельно зашиты в дэфолтный footer. Это все конечно поправимо, но зачастую на рефакторинг нет времени.

Благо у этой проблемы есть быстрое решение. Мы так же с помощью id можем связать форму (<form id={formId}) и кнопку (<button type="submit" form={formId}>). И они снова начнут хорошо работать в синергии.

Как видите во всех этих случаях, нам абсолютно не важно, насколько несуразные значения лежат в этих id-шниках(:r1:, :r2:, :r3:) Нам нужно просто связать эти поля и не очень то хочется придумывать всему этому уникальные имена. А если еще продолжать улучшать этот код. То можно создать отдельный generic компонент TextField. Который скроет в себе генерацию всех этих id-шек.

const TextField = ({ label, hint }) => {
  const inputId = useId();
  const hintId = useId();

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input name="firstName" id={inputId} aria-describedby={hintId} />
      <p id={hintId}>{hint}</p>
    </div>
  );
};

И если перейти к использованию этого компонента, то никто даже толком и не будет помнить на проекте, что там что то связано id-шками. Будут просто пользоваться компонентом TextField. Что очень упрощает жизнь.

const App = () => {
  const formId = useId();

  return (
    <div>
      <form id={formId} onSubmit={onSubmit}>
        <TextField label="First Name" hint="Enter first name" />
        <TextField label="Last Name" hint="Enter last name" />
        <TextField label="Email" hint="Enter email" />
      </form>
      <button type="submit" form={formId}>Submit</button>
    </div>
  );
};

Поля массивы

И как вы понимаете. Это были еще не самые сложные примеры форм. Я когда то работал над бухгалтерским приложением. Где я каждый день практически, создавал какие-то сложные формы с 10-ками полей. В многих формах встречались примеры с массивами вводимых данных.

Например в этой форме мы можем создать свой клуб, где сразу же есть возможность добавить неизвестное количество участников, а каждому участнику можно добавить нужное количество хобби. И в итоге вы даже не знаете сколько полей засабмитит пользователь, когда закончит свою работу. А вам еще нужно связать правильно все с помощью id, а вы даже не знаете сколько их понадобится пользователю (демка).

Теперь при создании таких форм, или админок, или сложных форм в банковском приложении я буду использовать хук useId. Это конечно не какая то серебряная пуля, но она точно немного облегчит мои рабочие будни.

Почему не uuid?

Теперь давайте ответим на вопрос, почему этот хук должен был стать частью React экосистемы. Почему бы не использовать просто npm пакет uuid или вообще не генерировать его с помощью Math.random.

Ответ достаточно простой - Server Side Rendering. Как мы все знаем React теперь выполняется не только на клиенте, но еще и на сервере. Что произойдет, если мы сгенерируем id через uuid. Оно выдаст одно значение на сервере. И когда произойдет первый рендер уже на клиенте, то мы увидим, что id изменился. И как следствие мы увидим вот такую ошибку в консоли.

Это значит что атрибут id на сервере и на клиенте не совпали. И что делать с этой ошибкой непонятно ведь никакие useState, useMemo или другие хуки не помогут вам решить эту проблему. Придется как то хардкодить такого рода id-шники.

React же взял эту проблему на себя. И предоставил нам магический хук useId. Который работает в любых условиях. У вас Single Page Application - не проблема. Или у вас Server Side Rendering - тоже не вопрос, а может вы ультрамодные и используете Server Components - это тоже не проблема. useId будет работать в любых условиях.

Как же это все работает?

Чтобы разобраться как эта вся магия работает, нужно конечно же изучать исходники. Но перед как мы погрузимся в исходники хочу напомнить один важный факт о хуках. Под капотом React команда вместо одного хука использует целых две функции. Первая это mountId, а вторая updateId.

Соответственно первая вызывается при первом рендере компонента, когда он только маунтиться. А второй метод вызывается на все последующие обновления компонента. Это дает большую гибкость в написании кода, больше не нужно создавать кучу if-ов. И эта логика касается не только хука useId. В одном из прошлых видео “Первое погружение в исходники хуков”, я разбирал исходники хуков и там вы можете более детально познакомиться как это все устроено.

А пока перейдем к функции mountId в исходниках React. Сначала мы получаем из root ноды некий identifierPrefix. О нем мы поговорим немного позже, не будем пока задерживаться.

function mountId(): string {
  const hook = mountWorkInProgressHook();

  const root = ((getWorkInProgressRoot(): any): FiberRoot);

  const identifierPrefix = root.identifierPrefix;

  // ...
}

А пока давайте перейдем к созданию сначала пустой переменной id. После идет ключевой if, который спрашивает isHydration. Что равноценно вопросу это Server Side Rendering? Т.е. тот самый компонент, который первый раз рендериться на сервере, а потом уже обновляется на клиенте.

function mountId(): string {
  // ...

  let id;
  if (getIsHydrating()) {
    // ...
  } else {
    // ...
  }

  // ...
}

В случае, если это SSR для основы id используется treeId, который одинаковый как на сервере так и на клиенте. В этом и кроется весь секрет одинаковой генерации id на сервере и на клиенте. И так же стоит упомянуть, что он уникален только в рамках компонента. Т.е. если у нас несколько useId в одном компоненте, то у всех них одинаковый treeId.

Чтобы это не было проблемой. Ниже к этому id добавляется еще localId - это просто счетчик от 0 и до бесконечности. Слово local означает, что он локален для текущего компонента. Т.е. у каждого компонента, счетчик начинается с ноля и дальше растет в зависимости сколько раз в рамках одного компонента вы используете хук useId.

function mountId(): string {
  // ...

  let id;
  if (getIsHydrating()) {
    const treeId = getTreeId();

    // Use a captial R prefix for server-generated ids.
    id = ':' + identifierPrefix + 'R' + treeId;

    // Unless this is the first id at this level, append a number at the end
    // that represents the position of this useId hook among all the useId
    // hooks for this fiber.
    const localId = localIdCounter++;
    if (localId > 0) {
      id += 'H' + localId.toString(32);
    }

    id += ':';
  } else {
    // ...
  }

  hook.memoizedState = id;
  return id;
}

В конечном итоге id-шники будут выглядеть следующим образом :R5lmcq:, :R5lmcqH1:, :R5lmcqH2:

Вот и вся логика генерации id для SSR. Если же у нас SPA приложение, то все еще проще. Есть глобальный counter. Слово глобальный значит, что один и тот же инстанс используется по всему React приложению во всех хуках useId.

function mountId(): string {
  // ...

  let id;
  if (getIsHydrating()) {
    // ...
  } else {
    // Use a lowercase r prefix for client-generated ids.
    const globalClientId = globalClientIdCounter++;
    id = ':' + identifierPrefix + 'r' + globalClientId.toString(32) + ':';
  }

  hook.memoizedState = id;
  return id;
}

Т.е. если у вас на весь проект всего 2 useId в разных компонентах. То у первого будет id равный :r1:, а у второго :r2:.

При этом интересный факт в том, что допустим у вас есть модальное окно и вы внутри него используете useId. Каждый раз когда вы будете монтировать модальное окно в ваше приложение, id будет меняться. Т.е. нет никакого механизма, чтобы восстановить прежнее id. Вы будете каждый раз, монтируя компонент в виртуальные дерево, генерировать новую id. Нужно это брать в расчет при написании кода.

Что такое identifierPrefix?

Осталось только разобраться, что такое ранее упомянутый identifierPrefix. На эту тему есть код из документации. Идея простая, если в вашем проекте инициируется несколько React приложений. В таком случае id-шники могут пересекаться. И чтобы избежать этой проблемы, дали возможность всем этим id-шникам добавить префикс.

import { createRoot } from 'react-dom/client';
import App from './App.js';

const root1 = createRoot(document.getElementById('root1'), {
  identifierPrefix: 'my-first-app-'
});
root1.render(<App />);

const root2 = createRoot(document.getElementById('root2'), {
  identifierPrefix: 'my-second-app-'
});
root2.render(<App />);

Изучим updateId( )

updateId куда проще, все что он делает это достает уже подсохраненный id и возвращает. Ведь вся идея в том, что id будет одинаковым на протяжении всей жизни компонента (Исходники).

function updateId(): string {
  const hook = updateWorkInProgressHook();
  const id: string = hook.memoizedState;
  return id;
}

Итоги

Попробуем подытожить все что мы узнали:

  • useId — решает реальные задачи. Да, это не что то революционное, но время от времени нам приходится иметь с этим дело и в этот момент я буду очень рад, что есть такой хук.

  • useId — не просто так является частью React экосистемы, потому что нам нужно уметь генерировать одинаковые id-шники как на сервере так и на клиенте. И React взял эту заботу на себя.

  • useId — возвращает непостоянные id-шники. Каждый раз открывая модалку вы будете получать новый id. Если вспомнить случай с перегрузкой страницы, то шансы есть, что id-шники останутся прежними, но это не более чем удача. Не знаю хорошо это или плохо. Нужно просто быть в курсе того как это работает.

  • Ну и конечно useId возвращает уникальные id только в рамках React приложения. Если ваш проект состоит из нескольких React приложений, не забудьте добавить префиксы, чтобы избежать проблем.

Надеюсь после этой статьи вы будете хоть время от времени использовать хук useId. Я точно планирую, хоть еще и не использовал. Ведь он действительно решает некоторые проблемы. А я люблю решать проблемы!

Комментарии (6)


  1. nin-jin
    10.07.2023 10:12

    const TextField = ({ label, hint })=> (
      <label>
        <p>{label}</p>
        <input name="firstName" />
        <p>{hint}</p>
      </label>
    )

    Отличный пример.


    1. Sin9k Автор
      10.07.2023 10:12

      Отличный пример чего?


      1. dasdasein
        10.07.2023 10:12

        А почему имя контроля захардкожено?


      1. nin-jin
        10.07.2023 10:12

        Бесполезности идентификаторов видимо.


        1. Sin9k Автор
          10.07.2023 10:12

          Вопросы у меня по двум моментам:

          • как скринридеры узнают, что hint это текст, который лежит после инпута в теге p?

          • label-ом оборачивать прям все, тоже не всегда хорошая идея, т.е. hint - это не label


  1. Arelis
    10.07.2023 10:12
    +1

    Довольно интересная статья, много нового узнал.