
화면에 숫자 0을 나타내고, +1 버튼을 누를 때마다 화면에 나타나는 숫자를 1씩 증가시키고 싶다. 어떻게 하면 될까?
export default function App() {
let num = 0;
function handleClick() {
num = num + 1;
}
return (
<>
<p>num</p>
<button onClick={handleClick}>+1</button>
</>
);
}
이렇게 지역변수를 업데이트하는 방식으로는 화면을 변경할 수 없다. 여기에는 두 가지 이유가 있다.
지역 변수는 렌더링 간에 유지되지 않는다.
지역 변수를 변경하더라도 렌더링이 발생하지 않는다.
useState 는 이 두 가지 문제를 해결해준다.
useState는 두 개의 값을 포함하는 배열을 반환한다.
const [num, setNum] = useState(0);
function handleClick() {
setNum(num + 1);
}
React에는 UI를 요청하고 제공하는 세 가지 단계가 있다.
컴포넌트에서 렌더링이 일어나는 조건은 두 가지이다.
컴포넌트 렌더링은 React에서 컴포넌트를 호출하는 것이다.
초기 렌더링에서는 React가 루트 컴포넌트를 호출한다. 이후에 발생하는 리렌더링에서는 state의 업데이트에 의해 트리거가 발생한 컴포넌트를 호출한다. 이 과정에서 만약 업데이트된 컴포넌트가 다른 컴포넌트를 포함하고 있다면, 해당 컴포넌트를 재귀적으로 렌더링하는 과정을 거친다.
컴포넌트 렌더링 이후 DOM을 수정한다. 단, DOM을 수정할 때는 렌더링 간에 차이가 발생한 부분에 대해서만 DOM의 노드를 변경한다.
React가 DOM 업데이트까지 마친 후 브라우저는 다시 화면을 그린다. 이 단계를 "브라우저 렌더링" 또는 "페인팅"이라고 부른다.
React는 컴포넌트를 리렌더링할 때 그 시점의 스냅샷을 찍고 반환한다. 그리고 해당 스냅샷과 일치하도록 화면을 업데이트 한다. React가 스냅샷을 찍을 때 state도 스냅샷에 포함된다. 이 때 찍힌 스냅샷을에 해당하는 state 값은 JSX에 적용되고, 이벤트 핸들러에도 해당 스냅샷이 적용된다.
아래 코드는 state의 스냅샷에 대해 이야기 할 때 언제나 등장하는 예시이다.
export default funtion App() {
const [num, setNum] = useState(0);
return (
<>
<p>num</p>
<button onClick={() => {
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
}}>+3</button>
</>
);
위 코드의 결과는 모두가 아는 것처럼 버튼을 한 번 누를 때마다 num이 3이 아니라 1씩 증가된다. 이 때 state의 스냅샷 개념이 적용된다.
먼저 App 컴포넌트가 초기 렌더링 될 때 React는 num에는 0이 할당한 상태로 컴포넌트의 스냅샷을 찍는다. 여기서 스냅샷을 찍는다는 것은 그 시점의 상태를 기억하고 있다는 뜻이 된다. 그렇다면 button의 onClick에 적용된 이벤트 핸들러에서 모든 setNum(num + 1)은 setNum(0 + 1)을 의미하게 된다.
즉, 이벤트 핸들러가 실행될 때 num을 업데이트 하면서 해당 값을 계속 사용하는 것이 아니라, 렌더링 되었을 때 찍은 스냅샷을 기준으로 한 num 값을 사용한다는 것이다.
하나의 이벤트 핸들러에서 하나의 state에 대해 여러번 업데이트 할 때마다 렌더링을 한다면 불필요한 리렌더링이 발생할 것이다. 따라서 React에서는 batching이라는 동작을 수행한다.
batching은 하나의 이벤트를 하나의 batch로 취급하고 수행한다. 즉, 이벤트 핸들러가 종료될 때까지 렌더링을 하지 않고 대기하다가 이벤트 핸들러의 모든 작업이 수행된 후에 최종적인 결과에 대해서만 렌더링을 수행한다.
<button onClick={() => {
setNum(num + 1); // 0 + 1 = 1, 렌더링 대기
setNum(num + 1); // 0 + 1 = 1, 렌더링 대기
setNum(num + 1); // 0 + 1 = 1, 렌더링 대기
// 이벤트 핸들러가 종료된 이후 리렌더링
}}>+3</button>
하나의 batch 안에서 상태 값을 업데이트 하고 그 값을 다시 참고하여 업데이트 하고싶을 수 있다. 이 때 n => n + 1 을 사용할 수 있고 이를 업데이터 함수(updater function)라고 한다.
<button onClick={() => {
setNum(n => n + 1); // 0 + 1 = 1
setNum(n => n + 1); // 1 + 1 = 2
setNum(n => n + 1); // 2 + 1 = 3
}}>+3</button>
업데이터 함수는 단순히 state 값을 대체하는 것이 아니라 React에 state 값으로 무언가를 하라 고 지시하는 것이다.
업데이터 함수는 이벤트 핸들러의 다른 코드가 모두 실행된 후 처리되도록 큐(Queue)로 들어간다. 큐의 작업들은 이벤트 핸들러의 코드가 모두 실행된 이후 순차적(FIFO)으로 수행된다.
큐에 들어간 업데이터 함수는 이전 state를 참조하여 state를 업데이트 한다.
useReducer는 useState와 동일한 기능을 수행한다. 단, useState와 다르게 dispatch와 reducer를 사용한다는 차이점이 존재한다.
// react 공식 문서 참조
const [state, dispatch] = useReducer(reducer, initialArg, init?);
function handleButtonClick() {
dispatch({ type: 'incremented_age' });
}
function handleInputChange(e) {
dispatch({
type: 'changed_name',
nextName: e.target.value
});
}
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
return {
name: state.name,
age: state.age + 1
};
}
case 'changed_name': {
return {
name: action.nextName,
age: state.age
};
}
}
throw Error('Unknown action: ' + action.type);
}
reducer는 state를 어떻게 업데이트 할 것인지 정의하는 함수이다. state와 action을 인수로 받아서 전달받은 action에 맞게 업데이트 될 state를 반환한다.
dispatch는 state를 reducer에 의해 반환된 값으로 업데이트 하고 리렌더링을 발생시킨다. action을 인수로 받아서 state 업데이트와 리렌더링 트리거를 수행한다. 반환하는 값은 없다.
useState, useReducer 둘 중 무엇을 선택할지는 개발자의 자유다.
단, 관리해야 할 state가 복잡하다면 useReducer를 사용하는 것이 유리할 것 같다. 이유는 다음과 같다.
https://ko.react.dev/learn/adding-interactivity
https://react.dev/blog/2022/03/08/react-18-upgrade-guide#automatic-batching
https://d-cron.tistory.com/77