원문 : https://blog.thoughtspile.tech/2021/11/15/unintentional-layout-effect/
useEffect
는 업데이트 차단을 방지하기 위해 페인트 이후에 동작해야 합니다. 하지만 실제로는 페인트 후에 useEffect
가 실행된다는 보장이 없다는 것을 알고 계셨나요? useLayoutEffect
에서 상태를 업데이트하면 동일한 렌더링의 모든 useEffect
가 페인트 전에 실행되므로 레이아웃 이펙트로 전환됩니다. 헷갈리시나요? 설명해드리겠습니다.
일반적인 흐름에서 리액트의 업데이트는 다음과 같이 진행됩니다.
1. 리액트 작업: 가상 DOM 렌더, 이펙트 스케쥴링, 실제 DOM 업데이트
2. useLayoutEffect
호출
3. 리액트가 제어를 해제하고, 브라우저가 새 DOM을 페인트 합니다.
4. useEffect
호출
리액트 문서에 레이아웃과 페인트 이후, 지연된 이벤트 중에 발생한다고 나와있지만, 정확히 언제 useEffect가 실행되는지 나와 있지 않습니다. 저는 항상 setTimeout(effect, 3)
이라고 생각했지만, 깔끔한 MessageChannel
트릭을 사용하는 것으로 보입니다.
하지만, 문서에 더 흥미로운 구절이 있습니다.
useEffect는 브라우저가 페인팅할 때까지 지연되지만, 새로운 렌더링이 시작되기 전에 실행되도록 보장됩니다. 리액트는 항상 새 업데이트를 시작하기 전에 이전 렌더링의 이펙트를 실행(flush) 합니다.
이것은 좋은 보장입니다. 업데이트를 놓치지 않도록 보장할 수 있습니다. 그러나 이는 때때로 이펙트 페인트 전에 실행될 수 있음을 의미하기도 합니다. a) 새 업데이트가 시작되기 전에 이펙트가 실행 되고, b) 페인트 전에 업데이트가 시작될 수 있는 경우(예를 들어, useLayoutEffect
를 통해 트리거 되는 경우) 이펙트는 페인트 전인 해당 업데이트 전에 실행 되어야 합니다. 다음은 이의 타임라인입니다.
useLayoutEffect
호출useEffect
호출useLayoutEffect
호출useEffect
호출이 상황은 드문 일이 아닙니다. useEffect
에서 상태를 업데이트할 수 없기 때문입니다. 상태를 업데이트하면 DOM이 업데이트되고, 페인트 후에 업데이트하면 사용자에게 오래된 프레임이 하나 남게 되어 눈에 띄는 깜박임이 발생합니다.
예를 들어, 입력이 300px
보다 넓은 경우에만 지우기 버튼을 렌더링하는 반응형 입력창(가짜 CSS 컨테이너 쿼리 와 같은)을 빌드해 보겠습니다. 입력창을 측정하려면 실제 DOM이 필요하므로 약간의 효과가 필요합니다. 또한 아이콘이 한 프레임 후에 나타나거나/사라지는 것을 원하지 않으므로 초기 측정값은 useLayoutEffect
로 들어갑니다.
const ResponsiveInput = ({ onClear, ...props }) => {
const el = useRef();
const [w, setW] = useState(0);
const measure = () => setW(el.current.offsetWidth);
useLayoutEffect(() => measure(), []);
useEffect(() => {
// 깊게 생각하지 말고, ResizeObserver라고 생각하세요.
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
return (
<label>
<input {...props} ref={el} />
{w > 200 && <button onClick={onClear}>clear</button>}
</label>
);
};
useEffect
로 페인트 후까지 addEventListener
를 지연시키려고 했지만, useLayoutEffect
의 상태 업데이트로 인해 페인트 전에 발생하도록 했습니다. (sandbox 참고)
업데이트가 초기 이펙트 실행을 강제하는 곳은 useLayoutEffect
뿐이 아닙니다. 호스트 참조(<div ref={HERE}>
), requestAnimationFrame
루프 및 useLayoutEffect에서 예약된 마이크로태스크도 동일한 동작을 발생시킵니다.
어떤 상황에서는 렌더링 흐름이 최적이 아닐 수도 있지만, 누가 신경 쓰겠습니까? 그래도 도구의 한계를 아는 것은 유용합니다. 다음은 4가지 실용적인 교훈입니다.
이런 함정을 알고 있더라도, 일부 useEffect
가 useLayoutEffect
에 영향을 받지 않도록 보장하는 것은 매우 어렵습니다.
useLayoutEffect
를 사용하지 않더라도 usePopper
같은 사용자 정의 훅이 사용하지 않는 것을 보장하나요?useLayoutEffect
상태 업데이트는 useContext
또는 부모의 리렌더를 통해 누수될 수 있습니다.useEffect
와 memo()
만 사용하더라도 업데이트의 이펙트는 전역적으로 플러시되므로 다른 컴포넌트의 사전 페인트 업데이트는 여전히 자식 이펙트를 실행합니다.많은 훈련을 통해 useLayoutEffect
에서 상태 업데이트가 없는 코드베이스를 가질 수 있지만, 그것은 초인적인 일입니다. 가장 좋은 조언은 useMemo
가 100% 안정적인 참조를 보장하지 않는 것처럼 페인트 후 useEffect
에 의존하지 말라는 것입니다. 사용자에게 한 프레임 동안 그려진 무언가를 보여주고 싶다면 useEffect
가 아닌 requestAnimationFrame
을 두 번 시도하거나 postMessage 트릭을 사용하세요.
반대로 리액트 팀의 좋은 조언을 듣지 않고 useEffect
에서 DOM을 업데이트했다고 가정해 봅시다. 테스트 해보니 깜박임이 없습니다. 나쁜 소식 - 아마도 페인트 하기 전의 상태 업데이트의 결과일 수 있습니다. 코드를 조금 수정하면 깜빡거릴 것입니다.
useEffect
vs useLayoutEffect
의 가이드라인을 그대로 따르면, 하나의 논리적 사이드 이펙트를 레이아웃 이펙트로 분할하여 DOM을 업데이트 하는 것과 ResponsiveInput
예시처럼 "지연된" 이펙트로 나눌 수 있습니다.
// DOM 엡데이트 = 레이아웃 이펙트
useLayoutEffect(() => setWidth(el.current.offsetWidth), []);
// 구독 = 지연 로직
useEffect(() => {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
하지만 이제 알다시피 이것은 아무 소용이 없습니다. 렌더링 전에 두 이펙트 모두 플러시됩니다. 게다가 분리도 엉성합니다. 페인트 후에 useEffect
가 발동한다고 가정하면 요소의 크기가 이펙트 사이에서 조정되지 않는다고 100% 확신할 수 있을까요? 그렇지 않습니다. 모든 크기 추적 로직을 단일 layoutEffect
에 남겨두는 것이 더 안전하고 깔끔하며, 사전 페인트 작업의 양이 동일하고, 리액트가 관리해야 할 이펙트가 줄어드는 더 좋은 방법입니다.
useLayoutEffect(() => {
setWidth(el.current.offsetWidth);
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
좋은 조언이지만 말처럼 쉽지는 않습니다. useEffect
는 상태를 업데이트하기에 더 나쁜 곳입니다. 왜냐하면 깜빡임은 나쁜 UX를 초래하고, UX는 성능보다 더 중요하기 때문입니다. 렌더링 중에 상태를 업데이트 하는 것은 위험해 보입니다.
가끔씩 상태를 useRef로 안전하게 대체할 수 있습니다. 참조를 업데이트해도 업데이트가 트리거되지 않고 의도한 대로 이펙트가 실행될 수 있습니다. 이러한 경우 중 몇 가지를 살펴보는 게시물이 있습니다.
가능하다면 이펙트에 의존하지 않는 상태 모델을 생각해 보시길 바라지만, 명령에 따라 "좋은" 상태 모델을 발명하는 방법은 잘 모르겠습니다.
특정 useLayoutEffect
가 문제를 일으키는 경우 상태 업데이트를 우회하고 DOM을 직접 변경하는 것을 고려해 보세요. 이렇게 하면 리액트가 업데이트를 스케쥴링하지 않고 이펙트를 열심히 실행할 필요가 없습니다. 이렇게 시도해보세요.
const clearRef = useRef();
const measure = () => {
// 걱정마, 리액트. 내가 처리할게.
clearRef.current.display = el.current.offsetWidth > 200 ? null : none;
};
useLayoutEffect(() => measure(), []);
useEffect(() => {
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
return (
<label>
<input {...props} ref={el} />
<button ref={clearRef} onClick={onClear}>clear</button>
</label>
);
이 방법은 이전 글인 useState
피하기에서 살펴본 적이 있는데, 이제 리액트 업데이트를 건너뛰어야 할 이유가 하나 더 생겼습니다. 하지만 DOM 업데이트를 수동으로 관리하는 것은 복잡하고 오류가 발생하기 쉬우므로 이 기법은 매우 중요한 상황(매우 중요한 컴포넌트 또는 매우 무거운 useEffect)에만 사용하세요.
오늘 우리는 가끔 useEffect
가 페인트 전에 실행되는 것을 발견했습니다. 자주 발생하는 원인은 useLayoutEffect
에서 상태를 업데이트 할 때 페인트 전에 다시 렌더링을 요청하고, 이펙트가 다시 렌더링하기 전에 실행되어야 하기 때문입니다. RAF 또는 마이크로태스크에서 상태를 업데이트할 때도 이런 문제가 발생합니다. 이것이 우리에게 의미하는 바는 다음과 같습니다.
useLayoutEffect
에서 상태를 업데이트 하는 것은 앱 성능에 좋지 않습니다. 이렇게 하지 않는 것이 좋지만, 때로는 좋은 대안이 없을 수도 있습니다.useEffect
에 의존하지 마세요. useEffect
에서 DOM을 업데이트하면 깜빡임이 발생할 수 있습니다. 아마 여러분이 깜빡임을 보지 못했다면, 그것은 레이아웃 이펙트에서 상태를 업데이트 했기 때문일 것입니다.useLayoutEffect
의 일부를 useEffect
로 추출하는 것은 의미가 없습니다.
좋은 정보 얻어갑니다, 감사합니다.