useState에서 setter가 비동기적으로 동작한다는 얘기는 다들 한 번쯤 들어보셨을 것 같습니다.
왜 비동기적으로 동작할까요? 비동기적으로 동작한다면 리액트를 사용하는 입장에서 어느 부분을 주의해야할까요?
두 가지 질문에 대해 정리해보고자 합니다. 이번 글에서는 왜 비동기적으로 동작하는지에 대해서만 정리해보겠습니다.
들어가기 전에 React 버전에 따른 동작 방식 차이에 대해서 짧게 짚고 넘어가겠습니다. 우선 우리가 현재 사용하는 React 18 부터는 기본적으로 상태 업데이트를 일괄 처리한다고 합니다. (+ 공식문서)
하지만 React 17과 그 이전 버전에서는 이벤트 핸들러 내부의 업데이트만 일괄 처리되었습니다. 따라서 이벤트 핸들러 외부에서는 (ex: AJAX 응답 핸들러 등)
상태 업데이트가 즉시 처리되었습니다. 즉, 다음과 같은 코드가 동기적으로 동작했습니다.
(아래의 예시는 setState를 바탕으로 작성되었습니다.)
promise.then(() => {
// 이벤트 핸들러가 아님. 따라서 즉시 처리 됨. (비효율적인 리렌더링 발생)
this.setState({a: 1}); // {a: 1, b: undefined }
this.setState({b: 2}); // {a: 1, b: 2 }
});
따라서 다음과 같이 일괄 처리를 위해서는 React에서 제공하는 API를 사용하여 강제로 일괄 처리 하는 작업이 필요했습니다.
promise.then(() => {
// Forces batching
ReactDOM.unstable_batchedUpdates(() => {
this.setState({a: 1}); // 리렌더링 되지 않음
this.setState({b: 2}); // 리렌더링 되지 않음
});
// {a: 1, b: 2 }. 리렌더링이 단 한 번만 됨.
});
이제 본격적으로 useState의 setter가 비동기적으로 동작하는 이유에 대해서 알아보겠습니다.
다음 내용은 공식 문서에서 언급한 글(gaearon git comment)을 바탕으로 정리하였습니다.
비동기적으로 동작하는 이유 중 하나는 렌더링의 효율성 때문입니다. 간단한 예시를 한 번 들어보겠습니다.
// setter 가 동기적으로 동작할 경우
<button onClick={() => {
setName('누구게');
setCurrentName('지금은 누구게');
setYourName('너는 누군데');
}}>이름 설정</button>
만약 setter가 동기적으로 동작한다면 위의 버튼을 누를 때 setter가 실행될 때마다 리렌더링을 해줘야하므로 총 세 번의 렌더링이 일어났을 것입니다.
setter를 모두 실행한 뒤 한 번의 렌더링을 해주는 것의 결과와 차이가 없으므로 불필요한 렌더링이 두 번이나 발생한 것이라고 볼 수 있겠습니다.
또한 부모 컴포넌트와 자식 컴포넌트 모두 state
를 setter 한다면, 부모 컴포넌트의 state
와 자식 컴포넌트의 state
가 갱신될 때마다 리렌더링 되므로, 자식 컴포넌트의 리렌더링 효율이 배로 떨어질 것이라고 예상해볼 수 있습니다.
위에서 언급한 리렌더링 효율성을 개선하기 위해 setter를 실행하더라도 동기적으로 업데이트는 하되 리렌더링이 즉시 되지 않고 일괄 처리한다고 추가로 가정해보겠습니다.
그렇다면 꽤 괜찮지 않아보이나요? 아래 코드는 적상적으로 동작할 것입니다.
// setter 가 동기적으로 동작 + 리렌더링을 일괄처리 할 경우
const [state, setState] = useState(0);
console.log(state) // 0
setState(state + 1);
console.log(state) // 1
setState(state + 1);
console.log(state) // 2
// 한 번만 렌더링
return <div>{state}</div> // 2
하지만 state
는 정상적으로 동작 하더라도 prop
은 그렇지 않아 React 객체(state, prop, ref)의 일관성이 깨지게 됩니다.
다음 코드를 보겠습니다.
// setter 가 동기적으로 동작 + 리렌더링을 일괄처리 할 경우
function Parent() {
const [state, setState] = useState(0);
return (
<Child value={state} increase={() => {
setState(state + 1)}}/>
);
}
function Child({ value, increase }) {
const increaseThreeTimes = () => {
console.log(value); // 0
increase();
console.log(value); // 0
increase();
console.log(value); // 0
increase();
console.log(value); // 0
}
return (
<button onClick={increaseThreeTimes}>
3번 더하기
</button>
);
}
위와 같은 결과가 나오는 이유는, 부모 컴포넌트가 리렌더링이 되지 않으면 prop
은 갱신되지 않기 때문에 그렇습니다.
보통의 경우 리액트를 사용하다보면 '상태 끌어올리기' 때문에 state
들이 점점 부모 컴포넌트로 가는 경향이 잦습니다.
따라서 부모 컴포넌트의 state
를 props
로 전달받아 사용하는 상황이 많은데요. 이런 경우에 setter를 동기적으로 동작하면서 일괄 처리를 할 때 문제가 됩니다.
결국 '렌더링 효율성을 포기할 것인가', 'React에서 제공하는 객체(state, prop, ref)의 일관성을 깨드릴 것인가' 둘 중 하나를 선택할 수 밖에 없는 상황이 발생하는 것입니다.
위와 같은 이유에 의해서 useState의 setter는 비동기적으로 동작한다고 합니다. 😀