Concurrent 기능: useTransition, useDeferredValue
React 18에서 도입된 Concurrent 기능은 UI를 차단하지 않고 상태를 업데이트할 수 있게 해줍니다. 이 글에서는 useTransition, useDeferredValue, useId, useLayoutEffect를 다룹니다.
useTransition
섹션 제목: “useTransition”useTransition은 UI를 차단하지 않고 상태를 업데이트할 수 있는 Hook입니다. useDeferredValue와 달리 함수의 실행을 다룹니다.
const App = () => { const [state, setState] = useState("state"); const [isPending, startTransition] = useTransition();
const handleState = (newState: string) => { startTransition(() => { setState(newState); }); };};startTransition으로 감싼 상태 업데이트는 transition 상태로 표시되며, isPending으로 현재 transition 중인지 판단할 수 있습니다.
React 18: 동기 코드만 가능
섹션 제목: “React 18: 동기 코드만 가능”React 18에서는 startTransition 안에 반드시 동기적인 코드를 사용해야 합니다.
// 동작하지 않음!startTransition(() => { setTimeout(() => { setState(newState); }, 10);});React 19: async 함수 지원
섹션 제목: “React 19: async 함수 지원”React 19부터는 startTransition에 async 함수를 전달할 수 있습니다.
const [isPending, startTransition] = useTransition();
const handleSubmit = () => { startTransition(async () => { const data = await fetchData(); setState(data); });};단, await 이후의 상태 업데이트는 별도의 startTransition으로 감싸야 transition으로 처리됩니다.
startTransition(async () => { const data = await fetchData(); startTransition(() => { setState(data); });});이는 JavaScript의 async context 한계 때문이며, 향후 AsyncContext가 지원되면 해소될 예정입니다.
useDeferredValue
섹션 제목: “useDeferredValue”useDeferredValue는 UI 일부의 업데이트를 지연시킬 수 있는 Hook입니다.
const [state, setState] = useState("12");const deferredValue = useDeferredValue(state);초기 렌더링 시 deferredValue는 state와 같은 값을 가지지만, setState로 업데이트되면 deferredValue는 이전 값을 유지한 채 백그라운드에서 천천히 새 값으로 전환됩니다.
무거운 컴포넌트와 함께 사용
섹션 제목: “무거운 컴포넌트와 함께 사용”무거운 컴포넌트에 prop을 전달할 때 useDeferredValue를 사용하면 입력의 반응성을 유지하면서 무거운 렌더링을 지연시킬 수 있습니다.
const MemoedHeavyComp = memo(HeavyComp);
const App = () => { const [state, setState] = useState("12"); const deferredValue = useDeferredValue(state);
return ( <> <input value={state} onChange={(e) => setState(e.target.value)} /> <MemoedHeavyComp input={deferredValue} /> </> );};memo와 함께 사용해야 효과가 있습니다. memo가 없으면 이전 값으로 리렌더링하는 것을 건너뛸 수 없기 때문입니다.
useId
섹션 제목: “useId”같은 컴포넌트를 여러 번 사용할 때 고유한 ID를 생성합니다.
const Form = () => { const id = useId();
return ( <div> <label htmlFor={`${id}-email`}> <input id={`${id}-email`} type="email" /> </label> <label htmlFor={`${id}-name`}> <input id={`${id}-name`} type="text" /> </label> </div> );};Math.random()을 쓰면 안 되는 이유
섹션 제목: “Math.random()을 쓰면 안 되는 이유”SSR 환경에서 서버와 클라이언트의 ID가 달라지는 hydration mismatch가 발생합니다. useId는 React 트리 구조 기반으로 결정론적으로 ID를 생성하므로 SSR에서도 안전합니다.
하나의 컴포넌트에서 여러 ID가 필요할 때
섹션 제목: “하나의 컴포넌트에서 여러 ID가 필요할 때”useId를 여러 번 호출하는 것보다 하나의 ID에 접미사를 붙이는 것이 권장됩니다.
const id = useId();// `${id}-email`, `${id}-name` 형태로 사용useLayoutEffect
섹션 제목: “useLayoutEffect”useEffect와 비슷하지만 렌더링 후 브라우저가 화면을 그리기 전에 동기적으로 실행됩니다.
useEffect의 문제
섹션 제목: “useEffect의 문제”function App() { const [show, setShow] = useState(false); const [top, setTop] = useState(0); const buttonRef = useRef(null);
useEffect(() => { if (!buttonRef.current || !show) return setTop(0); const { bottom } = buttonRef.current.getBoundingClientRect(); setTop(bottom + 30); }, [show]);
return ( <div> <button ref={buttonRef} onClick={() => setShow(!show)}>Show</button> {show && <div style={{ top: `${top}px` }}>tooltip</div>} </div> );}useEffect는 렌더링이 완료된 후 실행되므로 처음에 top: 0으로 렌더링되었다가 setTop(30)으로 다시 렌더링됩니다. 이 과정에서 요소가 0→30으로 이동하는 것처럼 보이는 깜빡임이 발생합니다.
useLayoutEffect로 해결
섹션 제목: “useLayoutEffect로 해결”useLayoutEffect는 브라우저가 화면을 그리기 전에 실행되므로, 처음부터 올바른 top 값으로 렌더링됩니다.
useEffect vs useLayoutEffect 비교
섹션 제목: “useEffect vs useLayoutEffect 비교”| 항목 | useEffect | useLayoutEffect |
|---|---|---|
| 실행 시점 | layout + paint 이후 (비동기) | layout 이후, paint 이전 (동기) |
| DOM 조작 시 | 깜빡임 발생 가능 | 깜빡임 없음 |
| 성능 영향 | 적음 | 무거운 로직 시 paint 지연 |
| 권장 용도 | 일반적인 사이드 이펙트 | DOM 측정 후 즉시 반영이 필요할 때 |
기본적으로 useEffect를 사용하고, 레이아웃 관련 깜빡임이 발생할 때만 useLayoutEffect로 전환하는 것이 좋습니다.
SSR에서의 useLayoutEffect
섹션 제목: “SSR에서의 useLayoutEffect”useLayoutEffect는 JavaScript가 다운로드 완료될 때까지 실행되지 않으므로, 서버 렌더링과 클라이언트 렌더링 결과가 다를 수 있습니다. 이를 해결하기 위해 isomorphic hook을 사용합니다.
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;서버 환경(window 미존재)에서는 useEffect를, 클라이언트에서는 useLayoutEffect를 사용합니다.