새로운 리액트 공식문서 beta - State as a Snapshot
기존 공식문서 바탕으로 리액트를 정리하다 보니, 함수형 + hooks 패러다임에서 확 와닿지 않게 돼 있는 경우가 많았다.
그 중에서도 가장 눈에 걸리는 부분이 바로 state 였는데, 새로운 문서를 읽어보니 setState()의 비동기성을 이렇게 이해했어야 맞다는 것을 깨달아버렸다...
한국어 번역이 어서 나오기를 기대하면서, 일단 이 문서는 사견을 자제하고 그대로 번역해보겠다.
State 변수가 읽고 쓸 수 있는 일반 JS 변수처럼 보일 수 있지만, State 는 변수보다는 스냅샷처럼 행동한다. setState()는 기존 State 변수를 바꾸는 게 아니라, 리렌더링을 트리거한다는 사실!
이번 문서에서는
- setState() 가 어떻게 리렌더링을 트리거하는지
- 언제, 어떻게 state가 업데이트되는지
- 왜 state가 setState() 이후에 즉시 업데이트 되지 않는지
- 이벤트 핸들러 함수가 state의 "스냅샷" 에 어떻게 접근하는지
를 배워보겠다.
클릭 과 같은 이벤트 직후에 UI가 곧바로 바뀐다고 생각했을 수 있다. 그러나, 리액트는 그것과는 조금 다르게 작동한다.
이전 페이지 에서 setState()가 리액트에게 리렌더링을 요청한다는 것을 배웠을 것이다. 다시 말하면, 인터페이스가 반응하게 하려면, state를 업데이트 해야 한다는 뜻이다!
다음 예시에서 "전송" 버튼을 누르면, setIsSent(true)
코드는 리액트에게 UI를 리렌더링 하라고 알려준다.
import { useState } from 'react';
export default function Form() {
const [isSent, setIsSent] = useState(false);
const [message, setMessage] = useState('Hi!');
// isSent state가 true면 메시지 보내는 중 렌더링
if (isSent) {
return <h1>메세지 보내는 중!</h1>
}
return (
<form onSubmit={(e) => {
e.preventDefault();
setIsSent(true);
sendMessage(message);
}}>
<textarea
placeholder="Message"
value={message}
onChange={e => setMessage(e.target.value)}
/>
<button type="submit">전송</button>
</form>
);
}
function sendMessage(message) {
// 메세지 전송하는 로직
}
마크다운에서 리액트 임포트를 어떻게 하는가.. 아무래도 못하겠지? velog의 한계... ㅠㅠ
버튼을 누르면 이렇게 됨 :
form
엘리멘트의 onSubmit
이벤트 핸들러가 실행됨
setIsSent(true)
코드는 isSent
state를 true
로 설정(set)하고, 새로운 렌더를 큐에 올려놓음
리액트는 새로운 isSent
값에 따라 컴포넌트를 리렌더링
이제 state와 렌더링 간 관계를 더 자세히 보자.
"렌더링" 이란 리액트가 컴포넌트를 불러온다는 뜻이다. 그런데 그 컴포넌트는 함수. 이 함수에서 return 되는 값은 JSX. 이 JSX는 특정 시점에서의 스냅샷과 같다. props, 이벤트 핸들러, 지역변수(*const, let)들은 렌더링 되는 시점의 state를 사용하여 계산된다는 것이다!
사진이나 영화의 프레임과는 다르게, UI의 "스냅샷"은 상호작용이 가능하다. 이 스냅샷들에는 인풋에 따라 어떤 결과가 나오는지를 특정하는 이벤트 핸들러 같은 로직들이 포함되어있다. 리액트는 이 스냅샷에 맞도록 화면을 업데이트하고, 이벤트 핸들러를 연결한다.
그 결과, 버튼을 누르는 것이 JSX의 클릭 핸들러를 촉발하는 것.
리액트가 함수를 다시 호출함
함수가 새로운 JSX 스냅샷 을 리턴함
리액트는 이 새로 만들어진 스냅샷 에 맞도록 화면을 업데이트
Illustrated by Rachel Lee Nabors - 이미지 출처는 공식문서
리액트가 함수 재실행
스냅샷 계산
DOM 트리 업데이트
함수 속 일반 변수는 함수가 리턴하면 사라진다. 그러나 state 는 리액트 그 자체에서 "살고 있다" - 함수 밖 리액트의 선반 속에 사는 것과 같다!
컴포넌트를 호출할 때, 리액트는 그 해당 렌더를 위한 state의 스냅샷을 준다. 컴포넌트는 그 렌더의 state 값을 이용하여 계산한 새로운 props와 이벤트 핸들러를 포함한 UI의 스냅샷을 리턴한다.
일러스트가 세 개 있는데 생략하겠음
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
// 버튼 한번 누르면 setState()를 세번 해보자!
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
</>
)
}
버튼을 한 번 누르면 어떻게 될까?
setNumber(number + 1)
가 세번 있으니까number
state가 0 → 3 이 되겠지?
놀랍게도 아니다. number
는 0 → 1 로 바뀜.
첫 렌더에서 number
는 0이었다. 그러니까 뭐다? 그 렌더의 onClick
이벤트핸들러 에서는 number
가 계속 0이라는 말이다! setNumber()
가 호출된 뒤에도.
// 아까 그 부분에서 버튼만 땀
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
버튼의 click 핸들러 동작을 자세히 살펴보자.
setNumber(number + 1)
: number
값이 0
이니까 → setNumber(0 + 1)
number
를 1
로 바꿀 준비함 setNumber(number + 1)
: number
값이 0
이니까 → setNumber(0 + 1)
number
를 1
로 바꿀 준비함 setNumber(number + 1)
: number
값이 0
이니까 → setNumber(0 + 1)
number
를 1
로 바꿀 준비함이 렌더링에서의 number
가 항상 0
이니까, number
값을 1
로 세번 만들어주는 것이다. 이벤트 핸들러 실행이 끝나도, 3
이 아니라 1
이 화면에 나오는 이유.
코드를 더 뜯어보면, 이렇게 되는 셈. (
이해가 됐으면 skip 해도 좋음. 공식문서 친절하군...)// 처음 누르면 <button onClick={() => { setNumber(0 + 1); setNumber(0 + 1); setNumber(0 + 1); }}>+3</button>
다음 렌더링에서
number
는1
. 그 렌더 에서의 클릭 핸들러는 이렇다.// 두번째 렌더에서 누르면 (= 세번째 렌더링 트리거) <button onClick={() => { setNumber(1 + 1); setNumber(1 + 1); setNumber(1 + 1); }}>+3</button>
setState() 이후 alert 하면 어떻게 될까?
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
// setState() 이후 alert 하면?
alert(number);
}}>+5</button>
</>
)
}
위에서 잘 배웠으면, alert는 0
을 띄울 것이라는 사실을 알 수 있을 것이다.
setNumber(0 + 5);
alert(0);
그렇다면 alert에 타이머를 붙인다면 ?????
리렌더링 이후 에 alert가 작동하게 된다면 ????
"0"이 나올까? "5"가 나올까?
<button onClick={() => {
//setState() 이후
setNumber(number + 5);
//setTimeOut 안에 alert
setTimeout(() => {
alert(number);
}, 3000);
}}>+5</button>
결과 : 화면이 5로 바뀐 다음 3초 뒤에 alert(0)이 나옴
뜯어보면 이렇다.
// number 값은 0 setNumber(0 + 5); // number 값은 0 setTimeout(() => { alert(0); }, 3000);
alert 가 작동하는 시점에 리액트에 저장된 state 값은 바뀌었을 지 모르나, 사용자가 버튼을 눌렀을 시점의 스냅샷 으로 alert 안 number
가 계산된 것이다!
이벤트 핸들러 코드가 비동기여도 마찬가지.
해당 렌더 의 onClick
안에서 setNumber(number+5)
가 아무리 호출돼도 number
값은 계속 0
이다. 리액트가 컴포넌트를 호출함과 동시에 찍힌 스냅샷에서 값이 고정된 것이다!
타이밍 실수를 줄이는 예시를 보여주겠다. 다음은 5초 딜레이 뒤에 메시지를 보내는 form. 시나리오는 이렇다 :
[전송]
버튼 눌러서 Alice에게 "안녕하세요"를 보낸다.To
state를 "Bob"으로 바꿔야 함.import { useState } from 'react';
export default function Form() {
const [to, setTo] = useState('Alice');
const [message, setMessage] = useState('안녕하세요');
function handleSubmit(e) {
e.preventDefault();
setTimeout(() => {
alert(`You said ${message} to ${to}`);
}, 5000);
}
return (
// 전송버튼 누르면 handleSubmit
<form onSubmit={handleSubmit}>
<label>
To:{' '}
<select
value={to}
// select 태그 이벤트핸들러 안에 setState
onChange={e => setTo(e.target.value)}>
<option value="Alice">Alice</option>
<option value="Bob">Bob</option>
</select>
</label>
// 인풋창
<textarea
placeholder="Message"
value={message}
onChange={e => setMessage(e.target.value)}
/>
// [전송] 버튼
<button type="submit">전송</button>
</form>
);
}
- state는 message, to
- 인풋창에 입력하면 setMessage. default값 "안녕하세요"
- select 창 선택하면 setTo
- 전송버튼 누르면 handleSubmit 실행
- handleSubmit 안에 state 출력하는 alert창
alert
는 무엇을 출력할까? (= 버튼 눌렀을 때 state는 무엇을 출력할까)
리액트가 한 렌더링에서 state값들을 고정하기 때문에 코드가 돌아가는 중에 state값이 바뀌었는지 걱정하지 않아도 된다.
그런데 리렌더링 전에 최신 state를 읽고 싶으면 어떻게 할까?
그럴 때는 다음 장에서 설명할 state updater 함수를 사용하자
useState
를 호출하면, 리액트가 해당 렌더링을 위한 state의 스냅샷을 준다.