콘텐츠로 이동

Concurrent 기능: useTransition, useDeferredValue

React 18에서 도입된 Concurrent 기능은 UI를 차단하지 않고 상태를 업데이트할 수 있게 해줍니다. 이 글에서는 useTransition, useDeferredValue, useId, useLayoutEffect를 다룹니다.

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에서는 startTransition 안에 반드시 동기적인 코드를 사용해야 합니다.

// 동작하지 않음!
startTransition(() => {
setTimeout(() => {
setState(newState);
}, 10);
});

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는 UI 일부의 업데이트를 지연시킬 수 있는 Hook입니다.

const [state, setState] = useState("12");
const deferredValue = useDeferredValue(state);

초기 렌더링 시 deferredValuestate와 같은 값을 가지지만, 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가 없으면 이전 값으로 리렌더링하는 것을 건너뛸 수 없기 때문입니다.

같은 컴포넌트를 여러 번 사용할 때 고유한 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>
);
};

SSR 환경에서 서버와 클라이언트의 ID가 달라지는 hydration mismatch가 발생합니다. useId는 React 트리 구조 기반으로 결정론적으로 ID를 생성하므로 SSR에서도 안전합니다.

하나의 컴포넌트에서 여러 ID가 필요할 때

섹션 제목: “하나의 컴포넌트에서 여러 ID가 필요할 때”

useId를 여러 번 호출하는 것보다 하나의 ID에 접미사를 붙이는 것이 권장됩니다.

const id = useId();
// `${id}-email`, `${id}-name` 형태로 사용

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는 브라우저가 화면을 그리기 전에 실행되므로, 처음부터 올바른 top 값으로 렌더링됩니다.

항목useEffectuseLayoutEffect
실행 시점layout + paint 이후 (비동기)layout 이후, paint 이전 (동기)
DOM 조작 시깜빡임 발생 가능깜빡임 없음
성능 영향적음무거운 로직 시 paint 지연
권장 용도일반적인 사이드 이펙트DOM 측정 후 즉시 반영이 필요할 때

기본적으로 useEffect를 사용하고, 레이아웃 관련 깜빡임이 발생할 때만 useLayoutEffect로 전환하는 것이 좋습니다.

useLayoutEffect는 JavaScript가 다운로드 완료될 때까지 실행되지 않으므로, 서버 렌더링과 클라이언트 렌더링 결과가 다를 수 있습니다. 이를 해결하기 위해 isomorphic hook을 사용합니다.

const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;

서버 환경(window 미존재)에서는 useEffect를, 클라이언트에서는 useLayoutEffect를 사용합니다.