useId — 고유 ID 생성 훅
React 18의 useId 훅이 해결하는 문제(SSR hydration 불일치, 중복 ID), 사용법, 접근성 aria 연결 패턴, 그리고 사용해서는 안 되는 경우를 설명합니다.
지난 글에서 useFetch 훅을 완성했다. 이번에는 완전히 다른 종류의 문제를 다룬다. 폼 컴포넌트에서 label과 input을 연결하려면 고유한 id가 필요하다. 직접 만들면 여러 문제가 생기는데, useId가 이를 우아하게 해결한다.
왜 useId가 필요한가
label의 htmlFor와 input의 id는 값이 일치해야 접근성이 작동한다.
// ❌ 하드코딩 ID — 같은 컴포넌트를 두 번 렌더하면 중복
<label htmlFor="email">이메일</label>
<input id="email" type="email" />
같은 EmailField 컴포넌트를 로그인 폼과 회원가입 폼에서 동시에 렌더하면 id="email"이 두 개 생겨 DOM이 충돌한다. 화면 낭독기가 잘못된 input을 연결할 수 있다.
// ❌ Math.random() — 서버와 클라이언트 값이 달라서 hydration 경고
const id = useRef(Math.random().toString(36).slice(2));
SSR에서는 서버가 렌더링한 HTML의 ID와 클라이언트의 hydration 과정에서 생성한 ID가 달라서 React가 경고를 출력하고 불필요한 DOM 업데이트가 발생한다.
기본 사용법
import { useId } from 'react';
function EmailField() {
const id = useId();
return (
<div>
<label htmlFor={id}>이메일</label>
<input id={id} type="email" />
</div>
);
}
useId는 :r0:, :r1:처럼 콜론으로 감싼 문자를 반환한다. 이 형식은 컴포넌트 트리 안에서의 위치를 기반으로 결정되므로, 서버와 클라이언트 렌더링 결과가 동일하다.
// 같은 컴포넌트를 두 번 렌더해도 각각 다른 ID
<EmailField /> // id=":r0:"
<EmailField /> // id=":r1:"
연관된 여러 ID에 접두어 패턴
하나의 컴포넌트에서 여러 연관 id가 필요할 때 useId를 한 번 호출하고 접두어를 붙인다.
function FormField({ label, description }) {
const id = useId();
const inputId = `${id}-input`;
const descriptionId = `${id}-description`;
return (
<div>
<label htmlFor={inputId}>{label}</label>
<input
id={inputId}
type="text"
aria-describedby={descriptionId}
/>
<p id={descriptionId}>{description}</p>
</div>
);
}
useId를 여러 번 호출하는 대신 하나의 접두어로 파생 ID를 만드는 패턴이 권장된다.
접근성 aria 속성에 활용
function Accordion({ title, children }) {
const id = useId();
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button
id={`${id}-btn`}
aria-expanded={isOpen}
aria-controls={`${id}-panel`}
onClick={() => setIsOpen(o => !o)}
>
{title}
</button>
<div
id={`${id}-panel`}
role="region"
aria-labelledby={`${id}-btn`}
hidden={!isOpen}
>
{children}
</div>
</div>
);
}
aria-expanded, aria-controls, aria-labelledby 같은 ARIA 속성들은 요소 간 ID로 관계를 표현한다. useId를 쓰면 이런 연결이 컴포넌트가 몇 번 렌더되든 항상 올바르게 유지된다.
사용해서는 안 되는 경우
// ❌ key prop에 useId 사용 금지
const id = useId();
return items.map((item, i) => (
<li key={`${id}-${i}`}> {/* 인덱스가 있으면 useId가 무의미 */}
{item.name}
</li>
));
key는 목록 아이템을 추적하는 용도이므로 데이터의 고유 ID(item.id)를 사용해야 한다.
// ❌ CSS selector에 직접 사용
const id = useId(); // ":r0:" → CSS에서 콜론은 특수 문자
document.querySelector(`#${id}`); // 콜론 이스케이프 필요
useId가 반환하는 값에는 콜론(:)이 포함된다. HTML id 속성으로는 유효하지만 CSS selector로 직접 쓰면 이스케이프가 필요하다. DOM을 직접 조작해야 한다면 다른 방법을 쓰는 게 낫다.
React 18 이전 대안
React 18 미만이거나 useId를 쓸 수 없는 환경에서는 전역 카운터 패턴을 쓴다.
let counter = 0;
function generateId() {
return `id-${++counter}`;
}
function useUniqueId() {
const [id] = useState(generateId);
return id;
}
useState의 지연 초기화를 이용해 컴포넌트마다 한 번만 ID를 생성한다. 단, SSR에서는 서버/클라이언트 카운터가 다를 수 있으므로 주의해야 한다.
지난 글: useFetch 커스텀 훅 — 데이터 페칭 완전 캡슐화
다음 글: useSyncExternalStore로 외부 상태 구독하기
읽어주셔서 감사합니다. 😊