useId는 접근성 속성에 전달할 수 있는 고유한 ID를 생성하기 위한 React Hook이에요.
const id = useId()
useId() {/useid/}컴포넌트의 최상위 레벨에서 useId를 호출해서 고유한 ID를 생성하세요:
// PasswordField.js
import { useId } from 'react';
function PasswordField() {
const passwordHintId = useId();
// ...
useId는 어떤 매개변수도 받지 않아요.
useId는 이 특정 컴포넌트에서 이 특정 useId 호출과 연관된 고유한 ID 문자열을 반환해요.
useId는 Hook이기 때문에, 컴포넌트의 최상위 레벨이나 여러분이 직접 만든 Hook 안에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 만약 그런 곳에서 사용해야 한다면, 새로운 컴포넌트를 추출하고 state를 그 안으로 옮기세요.
useId는 use()를 위한 캐시 키를 생성하는 데 사용해서는 안 돼요. ID는 컴포넌트가 마운트되어 있을 때는 안정적이지만, 렌더링 중에는 변경될 수 있어요. 캐시 키는 여러분의 데이터로부터 생성되어야 해요.
useId는 리스트에서 키를 생성하는 데 사용해서는 안 돼요. 키는 여러분의 데이터로부터 생성되어야 해요.
useId는 현재 비동기 서버 컴포넌트에서는 사용할 수 없어요.
⚠️ 주의
리스트에서 키를 생성하기 위해
useId를 호출하지 마세요. 키는 여러분의 데이터로부터 생성되어야 해요.
컴포넌트의 최상위 레벨에서 useId를 호출해서 고유한 ID를 생성하세요:
import { useId } from 'react';
function PasswordField() {
const passwordHintId = useId();
// ...
그런 다음 생성된 ID를 다양한 속성에 전달할 수 있어요:
<>
<input type="password" aria-describedby={passwordHintId} />
<p id={passwordHintId}>
</>
이게 언제 유용한지 예시를 통해 살펴볼게요.
aria-describedby와 같은 HTML 접근성 속성을 사용하면 두 태그가 서로 관련되어 있다는 것을 지정할 수 있어요. 예를 들어, 어떤 요소(예: input)가 다른 요소(예: paragraph)에 의해 설명된다고 지정할 수 있죠.
일반 HTML에서는 이렇게 작성하겠죠:
<label>
Password:
<input
type="password"
aria-describedby="password-hint"
/>
</label>
<p id="password-hint">
The password should contain at least 18 characters
</p>
하지만, React에서 이런 식으로 ID를 하드코딩하는 것은 좋은 방법이 아니에요. 하나의 컴포넌트가 페이지에 여러 번 렌더링될 수 있는데, ID는 고유해야 하거든요! ID를 하드코딩하는 대신에, useId로 고유한 ID를 생성하세요:
import { useId } from 'react';
function PasswordField() {
const passwordHintId = useId();
return (
<>
<label>
Password:
<input
type="password"
aria-describedby={passwordHintId}
/>
</label>
<p id={passwordHintId}>
The password should contain at least 18 characters
</p>
</>
);
}
이제, PasswordField가 화면에 여러 번 나타나더라도 생성된 ID가 충돌하지 않아요.
// App.js
import { useId } from 'react';
function PasswordField() {
const passwordHintId = useId();
return (
<>
<label>
Password:
<input
type="password"
aria-describedby={passwordHintId}
/>
</label>
<p id={passwordHintId}>
The password should contain at least 18 characters
</p>
</>
);
}
export default function App() {
return (
<>
<h2>Choose password</h2>
<PasswordField />
<h2>Confirm password</h2>
<PasswordField />
</>
);
}
input { margin: 5px; }
💡 부연설명: 위 예시에서
PasswordField컴포넌트가 두 번 렌더링되는데, 각각의useId()호출이 서로 다른 고유 ID를 반환하기 때문에aria-describedby와id가 올바르게 짝을 이루게 돼요. 만약 하드코딩된 ID를 사용했다면 같은 ID가 두 번 나타나서 HTML 표준을 위반하게 되고, 접근성 도구도 제대로 동작하지 않을 거예요.
이 영상을 보면 보조 기술을 사용할 때 사용자 경험에 어떤 차이가 있는지 확인할 수 있어요.
⚠️ 주의
서버 렌더링을 사용하는 경우,
useId는 서버와 클라이언트에서 동일한 컴포넌트 트리를 필요로 해요. 서버와 클라이언트에서 렌더링하는 트리가 정확히 일치하지 않으면, 생성된 ID도 일치하지 않을 거예요.
useId가 nextId++ 같은 전역 변수를 증가시키는 것보다 왜 더 나은지 궁금할 수 있어요.
useId의 주요 장점은 React가 서버 렌더링과 함께 동작하도록 보장한다는 거예요. 서버 렌더링 중에 여러분의 컴포넌트는 HTML 출력을 생성해요. 그 후에 클라이언트에서 hydration이 생성된 HTML에 이벤트 핸들러를 연결하죠. hydration이 동작하려면, 클라이언트 출력이 서버 HTML과 일치해야 해요.
증가하는 카운터를 사용하면 이걸 보장하기가 매우 어려워요. 왜냐하면 클라이언트 컴포넌트가 hydrate되는 순서가 서버 HTML이 생성된 순서와 다를 수 있거든요. useId를 호출하면, hydration이 제대로 동작하고 서버와 클라이언트 간의 출력이 일치하는 것을 보장할 수 있어요.
React 내부적으로, useId는 호출하는 컴포넌트의 "부모 경로(parent path)"로부터 생성돼요. 그래서 클라이언트와 서버 트리가 동일하다면, 렌더링 순서에 관계없이 "부모 경로"가 일치하게 되는 거예요.
💡 부연설명: 쉽게 말하면,
useId는 컴포넌트 트리에서의 위치를 기반으로 ID를 생성하기 때문에, 서버와 클라이언트에서 같은 컴포넌트 트리 구조를 가지고 있으면 동일한 ID가 생성돼요. 반면nextId++같은 카운터는 코드 실행 순서에 의존하는데, 서버와 클라이언트에서 실행 순서가 다를 수 있어서 ID 불일치가 발생할 수 있어요.
여러 관련 요소에 ID를 부여해야 한다면, useId를 호출해서 그것들을 위한 공유 접두사를 생성할 수 있어요:
// Form.js
import { useId } from 'react';
export default function Form() {
const id = useId();
return (
<form>
<label htmlFor={id + '-firstName'}>First Name:</label>
<input id={id + '-firstName'} type="text" />
<hr />
<label htmlFor={id + '-lastName'}>Last Name:</label>
<input id={id + '-lastName'} type="text" />
</form>
);
}
input { margin: 5px; }
이렇게 하면 고유한 ID가 필요한 모든 요소에 대해 useId를 각각 호출하지 않아도 돼요. 한 번만 호출해서 공유 접두사를 만들고, 거기에 각 요소별로 다른 접미사를 붙이면 되는 거예요.
하나의 페이지에 여러 개의 독립적인 React 애플리케이션을 렌더링하는 경우, createRoot나 hydrateRoot 호출에 identifierPrefix를 옵션으로 전달하세요. 이렇게 하면 두 개의 서로 다른 앱에서 생성된 ID가 절대 충돌하지 않아요. 왜냐하면 useId로 생성된 모든 식별자가 여러분이 지정한 고유한 접두사로 시작하거든요.
<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head><title>My app</title></head>
<body>
<div id="root1"></div>
<div id="root2"></div>
</body>
</html>
// App.js
import { useId } from 'react';
function PasswordField() {
const passwordHintId = useId();
console.log('Generated identifier:', passwordHintId)
return (
<>
<label>
Password:
<input
type="password"
aria-describedby={passwordHintId}
/>
</label>
<p id={passwordHintId}>
The password should contain at least 18 characters
</p>
</>
);
}
export default function App() {
return (
<>
<h2>Choose password</h2>
<PasswordField />
</>
);
}
// src/index.js
import { createRoot } from 'react-dom/client';
import App from './App.js';
import './styles.css';
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 />);
#root1 {
border: 5px solid blue;
padding: 10px;
margin: 5px;
}
#root2 {
border: 5px solid green;
padding: 10px;
margin: 5px;
}
input { margin: 5px; }
💡 부연설명: 위 예시에서
root1은'my-first-app-'이라는 접두사를 사용하고,root2는'my-second-app-'이라는 접두사를 사용해요. 그래서 각 앱 내에서useId()가 반환하는 값이 각각'my-first-app-:r1:','my-second-app-:r1:'같은 형태가 되어서 절대 충돌하지 않아요. 이건 마이크로 프론트엔드 아키텍처처럼 한 페이지에 여러 React 앱이 공존하는 경우에 특히 유용해요.
같은 페이지에 여러 독립적인 React 앱을 렌더링하고, 그 중 일부가 서버 렌더링되는 경우, 클라이언트 측의 hydrateRoot 호출에 전달하는 identifierPrefix가 renderToPipeableStream과 같은 서버 API에 전달하는 identifierPrefix와 동일한지 확인하세요.
// Server
import { renderToPipeableStream } from 'react-dom/server';
const { pipe } = renderToPipeableStream(
<App />,
{ identifierPrefix: 'react-app1' }
);
// Client
import { hydrateRoot } from 'react-dom/client';
const domNode = document.getElementById('root');
const root = hydrateRoot(
domNode,
reactNode,
{ identifierPrefix: 'react-app1' }
);
페이지에 React 앱이 하나만 있다면 identifierPrefix를 전달할 필요가 없어요.
💡 부연설명: 핵심은 서버와 클라이언트에서 동일한
identifierPrefix를 사용해야 한다는 거예요. 그래야 서버에서 생성된 HTML의 ID와 클라이언트에서 hydration 시 생성되는 ID가 정확히 일치해서, hydration 과정에서 불일치 에러가 발생하지 않아요.