useId, useSyncExternalStore, useDeferredValue — 잘 안 쓰이지만 중요한 훅들
React에는 useState, useEffect 외에도 특수한 상황에서 빛을 발하는 훅들이 있습니다. 이런 훅들은 언제 필요하고, 어떤 문제를 해결할까요?
useId — SSR 안전한 고유 ID
개념 정의
useId는 서버와 클라이언트에서 동일한 고유 ID를 생성 하는 훅입니다. 접근성(accessibility) 속성이나 폼 요소의 id-htmlFor 연결에 사용합니다.
왜 필요한가
// ❌ Math.random() — SSR 시 서버/클라이언트 불일치
function Input({ label }) {
const id = `input-${Math.random()}`; // 매번 다른 값
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
// ❌ 전역 카운터 — 렌더 순서에 의존
let counter = 0;
function Input({ label }) {
const id = `input-${counter++}`; // 서버/클라이언트 순서가 다를 수 있음
}
// ✅ useId — SSR 안전
function Input({ label }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
여러 요소에 활용
function PasswordField() {
const id = useId();
return (
<div>
<label htmlFor={`${id}-password`}>비밀번호</label>
<input id={`${id}-password`} type="password" aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>8자 이상, 특수문자 포함</p>
</div>
);
}
주의: useId로 생성된 값을 **리스트의 key로 사용하면 안 됩니다 **. key는 데이터에서 파생되어야 합니다.
useSyncExternalStore — 외부 스토어 구독
개념 정의
useSyncExternalStore는 React 외부의 데이터 소스를 안전하게 구독 하는 훅입니다. Concurrent Mode에서 발생할 수 있는 tearing(찢어짐) 문제를 방지합니다.
Tearing 문제
Concurrent Mode에서 렌더링 도중 외부 스토어가 변경되면:
컴포넌트 A: 이전 값(10)을 읽음
→ 렌더링 중단 (긴급 업데이트 처리)
→ 외부 스토어 값 변경 (10 → 20)
컴포넌트 B: 새 값(20)을 읽음
→ 같은 렌더 트리에서 다른 값이 표시됨 (tearing)
사용법
import { useSyncExternalStore } from 'react';
// 외부 스토어 예시: 브라우저 온라인 상태
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // 구독 함수
getSnapshot, // 현재 값을 읽는 함수
getServerSnapshot // SSR용 (선택)
);
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true; // SSR에서는 항상 온라인으로 가정
}
// 사용
function StatusBar() {
const isOnline = useOnlineStatus();
return <span>{isOnline ? '🟢 온라인' : '🔴 오프라인'}</span>;
}
외부 상태 관리 라이브러리와의 관계
Zustand, Redux 등의 라이브러리가 내부적으로 useSyncExternalStore를 사용합니다.
// Zustand가 내부적으로 하는 일 (개념적)
function useStore(selector) {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(store.getState())
);
}
useDeferredValue — 긴급하지 않은 업데이트 지연
개념 정의
useDeferredValue는 UI의 일부 업데이트를 ** 지연시켜** 긴급한 업데이트(타이핑 등)가 먼저 처리되도록 합니다.
사용법
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// query는 즉시 업데이트 (입력 필드 반응)
// deferredQuery는 여유가 생기면 업데이트 (검색 결과 반영)
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</div>
);
}
// SearchResults가 무거운 렌더링을 수행하더라도 입력이 버벅이지 않음
const SearchResults = memo(function SearchResults({ query }) {
const results = computeExpensiveResults(query);
return <ul>{results.map(r => <li key={r.id}>{r.text}</li>)}</ul>;
});
useDeferredValue vs debounce
| 특성 | useDeferredValue | debounce |
|---|---|---|
| 지연 시간 | React가 자동 조절 | 고정 시간 (예: 300ms) |
| 동작 방식 | 긴급 업데이트 우선 처리 | 일정 시간 후 실행 |
| 취소 | React가 자동 관리 | 수동 관리 필요 |
| Suspense 통합 | 지원 | 미지원 |
// debounce: 항상 300ms 기다림
const debouncedQuery = useDebounce(query, 300);
// useDeferredValue: React가 여유가 있으면 즉시, 바쁘면 지연
const deferredQuery = useDeferredValue(query);
빠른 기기에서는 useDeferredValue가 거의 즉시 업데이트되고, 느린 기기에서는 더 오래 지연됩니다. ** 디바이스 성능에 자동으로 적응 **하는 것이 핵심 장점입니다.
useInsertionEffect — CSS-in-JS를 위한 훅
개념 정의
useInsertionEffect는 DOM mutation 전에 실행되는 훅입니다. CSS-in-JS 라이브러리가 <style> 태그를 주입하는 데 특화되어 있습니다.
실행 순서
렌더링 → useInsertionEffect → DOM mutation → useLayoutEffect → 브라우저 paint → useEffect
사용법
// ⚠️ 일반 개발자가 직접 사용할 일은 거의 없음
// CSS-in-JS 라이브러리 개발자를 위한 훅
function useCSS(rule) {
useInsertionEffect(() => {
const style = document.createElement('style');
style.textContent = rule;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
});
}
// styled-components, emotion 등이 내부적으로 사용
일반적인 애플리케이션 코드에서는 **useInsertionEffect를 직접 사용할 일이 거의 없습니다 **. DOM 측정이나 레이아웃 관련 작업에는 useLayoutEffect를 사용합니다.
useTransition과의 비교
useDeferredValue와 자주 비교되는 useTransition도 함께 알아두면 좋습니다.
// useTransition: state 업데이트를 낮은 우선순위로 표시
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('home');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab); // 이 업데이트는 낮은 우선순위
});
}
return (
<div>
<TabButton onClick={() => selectTab('home')}>홈</TabButton>
<TabButton onClick={() => selectTab('posts')}>글</TabButton>
{isPending && <Spinner />}
<TabPanel tab={tab} />
</div>
);
}
| 특성 | useTransition | useDeferredValue |
|---|---|---|
| 대상 | state 업데이트 자체를 지연 | 값의 반영을 지연 |
| 제어 | 업데이트를 시작하는 쪽 | 업데이트를 소비하는 쪽 |
| pending 상태 | isPending 제공 | 직접 비교 (value !== deferred) |
주의할 점
useId로 생성한 ID를 key로 사용하면 안 됨
useId는 컴포넌트 인스턴스당 하나의 ID를 생성합니다. 리스트 렌더링에서 key로 사용하면 모든 항목이 같은 ID를 갖게 됩니다. key는 데이터의 고유 식별자를 사용해야 합니다.
useSyncExternalStore 없이 외부 스토어를 구독하면 tearing 발생
Concurrent Mode에서는 렌더링 도중 외부 값이 변경될 수 있습니다. useState + useEffect로 외부 스토어를 구독하면 화면의 일부가 다른 버전의 데이터를 표시하는 tearing 현상이 발생합니다.
useDeferredValue를 debounce 대용으로 오해
debounce는 고정 시간을 기다리지만, useDeferredValue는 React의 스케줄러가 여유 시간에 업데이트합니다. 빠른 기기에서는 거의 즉시, 느린 기기에서는 더 오래 지연됩니다. 네트워크 요청 최적화에는 debounce가, 렌더링 최적화에는 useDeferredValue가 적합합니다.
정리
| 항목 | 설명 |
|---|---|
| useId | SSR 안전한 고유 ID — 접근성 속성(aria-*) 연결에 필수 |
| useSyncExternalStore | 외부 스토어를 Concurrent Mode에서 안전하게 구독 |
| useDeferredValue | 긴급하지 않은 업데이트를 지연하여 UI 반응성 유지 |
| useInsertionEffect | CSS-in-JS 라이브러리 전용 — 일반 개발에서 사용할 일 거의 없음 |
| useTransition | state 업데이트 자체를 낮은 우선순위로 처리 |
이 훅들은 자주 쓰이지는 않지만, 각각이 해결하는 문제를 알면 적절한 상황에서 정확히 사용할 수 있습니다.