useState Hook, setState가 인자로 함수를 전달받는 이유?
멋사에서 useState Hook을 배우던 중 setState의 인자로 값을 전달하는 것과 함수를 전달하는 것은 무엇에서 차이가 있는지 궁금했다.
이전에 했던 프로젝트에서도 useState Hook을 사용했던 적은 있었지만 "useState는 상태를 관리해주는 Hook이고, setState에 어떤 값을 넣어주면 그 값으로 상태가 변경돼~" 정도만 알고 사용했다. 따라서 자세한 동작 방식을 알고 setState에 함수를 전달해주는 이유를 정리하고자 글을 쓰게 되었다.
setState, 무엇일까?
useState가 실행되면 배열을 반환한다. 반환된 배열의 첫 번째 인덱스에는 현재 상태값(state), 두번째 인덱스에는 상태를 변경하는 함수(setState)가 저장되어 있다. 즉, setState를 통해 상태를 변경할 수 있고, 상태가 변경되면 컴포넌트가 리렌더링된다.
setState, 어떻게 동작할까?
setState 함수의 인자에 값이나 함수를 넣는 이유를 알아보기 전에 setState가 어떻게 동작하는지를 먼저 알아보자.
결론부터 먼저 말하자면 setState 함수는 비동기로 동작한다!
다음의 예제를 보자.
'증가' 버튼을 클릭해 이벤트 핸들러 함수 clickBtn이 실행되고 setState 함수를 연속적으로 3번 호출한다. 이는 숫자가 +3씩 증가할 것을 기대하고 짠 코드지만 실제로는 +1씩 증가한다.
🤔 왜 이렇게 동작할까?
위의 코드와 아래의 간단하게 만든 useState 코드와 함께 살펴보자. (실제로 리액트 훅의 구현은 훨씬 더 복잡할 것이다.)
리액트 어플리케이션이 로딩되어 App 함수가 실행되면 위의 코드는 다음과 같이 동작할 것이다.
- useState 함수가 호출이 된다. initialState로 0이 전달되어 state를 0으로 저장하고, 현재 state와 setState 함수를 배열로 반환한다. 구조분해할당을 통해 리턴된 배열의 각 인덱스에 해당하는 값을 변수에 할당한다.
- '증가' 버튼을 클릭하면 이벤트 핸들러 함수 clickBtn가 호출되고, 차례대로 3번의 setCount 함수가 호출된다.
- 첫 번째 setCount 함수가 호출되고 인자로는 1이 주어질 것이다. 이때, 리액트는 리렌더링을 미루어 상태가 업데이트되지 않는다.
- 상태가 업데이트되지 않은 채로 두 번째 setCount 함수가 호출되고 인자로 역시 1이 주어질 것이다. 이때도, 리액트는 리렌더링을 미루어 상태가 업데이트되지 않는다.
- 상태가 업데이트되지 않은 채로 세 번째 setCount 함수가 호출되고 인자로 역시 1이 주어질 것이다.
- 이벤트 핸들러가 끝나는 시점이 되어 상태 업데이트가 일괄적으로 처리된다. 따라서 state의 값은 1로 변경되고, 상태가 변경되어 컴포넌트가 리렌더링된다.
- 리렌더링되면서 다시 App 함수가 실행되고 useState 함수가 호출된다. 리액트의 state값은 유지되므로 state는 1이 될 것이다. 그리고 위의 과정들이 반복될 것이다.
상태변경 코드가 순서대로(동기적으로) 처리되어 상태 변경 리렌더링이 반복되는 것이 아니라 이벤트 핸들러가 끝나기를 기다렸다가 상태를 일괄적으로 처리해 업데이트한다. 이를 batch update 방식이라고 한다.
📌 리액트에서 batching이란?
React가 더 나은 성능을 위해 여러 개의 state 업데이트를 하나의 리렌더링 (re-render)로 묶는 것을 말한다. 이벤트 핸들러 내의 setState 함수가 batch update를 통해 비동기로 동작하였으나 리액트 18버전 부터는 자동 배칭이 등장하여 timeout, promise, fetch에서도 자동 배칭을 통해 렌더링을 최소화할 수 있다.
리액트는 setState로 상태값이 변경되면 batch update -> merge -> reconcilation -> re-render 순서로 동작하게 된다.
16ms 단위로 batch update를 진행하여 이 시간동안 변경된 상태를 기존의 state에 병합한다(merge). 이때, state는 객체이므로 상태가 아무리 변경되어도 변경된 키 값이 같으면 마지막 값을 덮어쓰게 될 것이다. 리액트 엘리먼트 트리와 변경된 state가 적용된 엘리먼트 트리를 비교하는 작업(reconciliation)을 거쳐 최종적으로 변경된 부분만 DOM에 적용시킨다.
비동기로 동작하는 이유?
setState가 비동기적으로 동작하는 이유는 리액트가 렌더링 성능을 향상시키기 위함과 관련이 있다. 리액트는 상태값을 업데이트 할 때마다 바로 리렌더링 되는 것이 아니라 변경사항을 모아 브라우저 이벤트가 끝날 시점에 state를 일괄적으로 처리(Batch Update)를 하여 컴포넌트의 렌더링 횟수를 최소화한다. 이를 통해 불필요한 렌더링을 방지해 어플리케이션의 속도를 향상시킬 수 있는 것이다.
setState, 함수를 전달해보자
그렇다면 의도하고자 하는 setState를 연속적으로 호출하여 숫자 상태를 3씩 증가시키기 위해서는 어떻게 해야 할까?
setState에 함수를 전달하면 된다. 공식 리액트 사이트에는 이 전달되는 함수를 updater 함수라고 기술되어 있다. updater 함수를 전달하면 updater 함수 안에서 이전 state 값에 접근할 수 있다.
또 한번 위의 App 코드와 아래의 간단하게 만든 useState 코드와 함께 살펴보자. 아래의 useState 함수 코드에는 setState 함수가 updater 함수를 인자로 받았을 때 updater 함수를 실행시키는 코드가 추가되었다. (실제로 리액트 훅의 구현은 훨씬 더 복잡할 것이다.)
리액트 어플리케이션이 로딩되어 App 함수가 실행되면 위의 코드는 다음과 같이 동작할 것이다.
- useState 함수가 호출이 된다. initialState로 0이 전달되어 state를 0으로 저장하고, 현재 state와 setState 함수를 배열로 반환한다. 구조분해할당을 통해 리턴된 배열의 각 인덱스에 해당하는 값을 변수에 할당한다.
- '증가' 버튼을 클릭하면 이벤트 핸들러 함수 clickBtn가 호출되고, 차례대로 3번의 setCount 함수가 호출된다.
- 첫 번째 setCount 함수가 호출되고 인자로 updater 함수가 전달된다. 이때, 리액트는 리렌더링을 미루어 상태가 업데이트되지 않는다.
- 상태가 업데이트되지 않은 채로 두 번째 setCount 함수가 호출되고 인자로 updater 함수가 전달된다.. 이때도, 리액트는 리렌더링을 미루어 상태가 업데이트되지 않는다.
- 상태가 업데이트되지 않은 채로 세 번째 setCount 함수가 호출되고 인자로 updater 함수가 전달된다.
- 이벤트 핸들러가 끝나는 시점이 되어 상태 업데이트가 일괄적으로 처리된다. 이때, updater 함수를 전달하게 되면 항상 최신의 state를 참조하여 상태를 업데이트 시키는 것이 보장이 된다. 위의 useState 코드에서는 newState 함수에 state 값을 넣어주었기 때문에 최신의 state 값을 참조하는 것이 가능하다.
- 리렌더링되면서 다시 App 함수가 실행되고 useState 함수가 호출된다. 리액트의 state값은 유지되므로 state는 3이 될 것이다. 그리고 위의 과정들이 반복될 것이다.
따라서 setState에 updater 함수를 전달하면 바로 이전 state 값, 즉 가장 최신 state 값을 참조해 상태를 변경할 수 있어 상태의 업데이트가 차례대로 반영되고, 배치 처리되어 일괄적으로 한번에 리렌더링되는 장점이 있다.