view에 반영되는 state가 있다.
onClick
핸들러를 누르면alert
) 를 실행하고 싶다.import React from 'react';
export default function App(props) {
console.log('rendering');
const [state, setState] = React.useState(0);
const handleClick = () => {{
console.log('handleClick');
setState((v) => v + 1);
alert('blocking');
};
return (
<button onClick={handleClick}>
{state}
</button>
);
}
해당 코드를 실행해 보면 당연히 repaint 전에 alert가 표시되게 된다.
setState는 다음 스냅샷을 위한 commit을 할 뿐이지 동기적인 업데이트 과정이 아니다.
따라서 다음 리렌더가 발생하기 전에 alert가 표시된다.
ReactDOM.flushSync
ReactDOM.flushSync
는 두 가지 목적을 위해 사용된다.
우리가 사용하는 목적은 두 번째이다.
import React from 'react';
import ReactDOM from 'react-dom';
export default function App(props) {
console.log('rendering');
const [state, setState] = React.useState(0);
const handleClick = () => {
ReactDOM.flushSync(() => {
setState((v) => v + 1);
});
alert('blocking');
};
return (
<button onClick={handleClick}>
{state}
</button>
);
}
이상하게도 이렇게 코드를 변경해도 원하던 동작이 나오지 않는다.
ref
를 활용해 DOM이 제대로 업데이트 되는지 확인해 보자.
import React from 'react';
import ReactDOM from 'react-dom';
export default function App(props) {
console.log('rendering');
const [state, setState] = React.useState(0);
const ref = React.useRef(null);
const handleClick = () => {
ReactDOM.flushSync(() => {
setState((v) => v + 1);
});
console.log(ref.current.textContent);
alert('blocking');
};
return (
<button ref={ref} onClick={handleClick}>
{state}
</button>
);
}
해당 코드를 실행해 보니 console에는 DOM이 정상적으로 업데이트된다.
flushSync
는 동기적 DOM 업데이트를 보장하지만 repaint는 보장하지 않는다.
따라서 alert
는 repaint 이전에 실행되게 된다.
flushSync
만으로는 목적 달성이 불가능하다는 것을 확인할 수 있다.
requestAnimationFrame
rAF
를 활용해 repaint 이후로 alert의 실행을 지연시켜 보자.
const handleClick = () => {
ReactDOM.flushSync(() => {
setState((v) => v + 1);
});
requestAnimationFrame(() => {
alert('blocking');
})
};
이래도 원하는 동작이 나오지 않는다.
왜냐하면 rAF
콜백은 다음 repaint 직후가 아닌 직전에 실행되기 때문이다.
MessageChannel
hackReact가 repaint 이후 useEffect를 실행하는 원리인 MessageChannel
hack을 이용해 repaint 직후의 callback을 구현할 수 있다.
import React from 'react';
import ReactDOM from 'react-dom';
export default function App(props) {
console.log('rendering');
const [state, setState] = React.useState(0);
const handleClick = () => {
ReactDOM.flushSync(() => {
setState((v) => v + 1);
});
const channel = new MessageChannel();
channel.port1.onmessage = function () {
console.log('after repaint');
alert('blocking');
};
requestAnimationFrame(function () {
console.log('before repaint');
channel.port2.postMessage(undefined);
});
};
return (
<button onClick={handleClick}>
{state}
</button>
);
}
이제 원하던 대로 동작한다.
위 구현에 다른 고민거리가 생겼다.
rAF
는 repaint 시점 기준이기 때문에 예시 코드 기준으로는 flushSync
를 제거하더라도 동일하게 동작한다.
flushSync를 제거하려면 아래와 같은 확신이 필요했다:
우연히 외부에서 예약된 repaint와
rAF
가 겹쳐 우리가 의도한 frame 이 아닌 미리 예약되었던 frame 직후 alert가 표시되는 상황이 없음
그래서 아래와 같이 테스트를 진행했다.
import React from 'react';
export default function App(props) {
console.log('rendering');
const [state, setState] = React.useState(0);
const ref = React.useRef(null);
const _handleClick = () => {
ref.current.textContent = 'unintended frame';
setTimeout(handleClick, 0);
};
const handleClick = () => {
setState((v) => v + 1);
const channel = new MessageChannel();
channel.port1.onmessage = function () {
console.log('after repaint');
console.log(ref.current.textContent);
alert('blocking');
};
requestAnimationFrame(function () {
console.log('before repaint');
console.log(ref.current.textContent);
channel.port2.postMessage(undefined);
});
};
return (
<button ref={ref} onClick={_handleClick}>
{state}
</button>
);
}
다행히 반례 상황의 재현에 성공해 flushSync
가 필요하다는 확신을 가질 수 있었다.
(매 번 발생하는 것은 아니고 여러번 시도했을경우 간혹 일어났다. flushSync
를 사용했을 경우에는 해당 현상이 발생하지 않았다.)
실무에서 이런 요구사항을 만날 일은 드물겠지만, 다양한 고민을 해볼 수 있어 좋았다.