setState
후 console.log(state)
를 했을 때 업데이트가 안 되어서 당황하는 리액트 초심자분들을 위한 글입니다.
const Component = () => {
const [data, setData] = useState();
useEffect(() => {
axios.get('/api/users/me').then((response) => {
console.log(response.data); // ✅ 내가 원했던 제대로 된 데이터
setData(response.data);
console.log(data); // ❌ undefined.. 왜?
});
}, [];
앞에서도 말했듯, 이 글은 위 예제에서 console.log(data);
가 왜 undefined
를 출력하는지에 대한 글입니다.
특히 리액트 초심자의 경우 Promise
도 익숙하지 않고 react
도 익숙하지 않기 때문에 헷갈릴 여지가 있는 것 같습니다.
결론부터 말하면 정상적인 상황이고, 의도된 동작입니다. console.log
를 렌더 레벨로 한 단계 올리면 data가 정상적으로 반영되어 있습니다.
const Component = () => {
const [data, setData] = useState();
useEffect(() => {
axios.get('/api/users/me').then((response) => {
console.log(response.data); // ✅ 내가 원했던 제대로 된 데이터
setData(response.data);
});
}, [];
console.log(data); // ✅ 내가 원했던 제대로 된 데이터
먼저 "왜 그렇게 되는지"부터 알아보고, 그런 다음 "왜 리액트는 그렇게 만들어졌는지"를 알아보겠습니다.
우선 Promise 가 끼면 머리아프니까, 복잡도를 낮추기 위해 예제를 조금 더 단순화하겠습니다.
아래 컴포넌트는 마운트될 때 count
를 1 증가시킵니다. 실제로 의미가 있는 컴포넌트는 아닐 것 같지만, 오늘의 주제를 다루기에는 가장 적합합니다.
const Component = () => {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(1);
console.log(count); // ❌ 0이네..? 왜?
}, [];
그동안의 삶의 지혜에 따르면, 이 코드는 아래처럼 읽힙니다. (왠지 당연히 이런 식으로 동작할 것 같습니다.)
const Component = () => {
let count = 0;
useEffect(() => {
count = 1;
console.log(count); // 1을 넣었으니까 당연히 1이어야 하지 않을까?
}, [];
비유하자면 Mike 가 구슬이 0개 들어있는 구슬주머니를 들고 있다고 했을 때, 여기에 구슬을 1개 넣으면 Mike 가 들고 있는 구슬 주머니에 구슬이 1개가 되는 게 당연해 보입니다.
하지만 리액트 함수컴포넌트와 setState 는 이렇게 동작하지 않습니다. 오히려 아래와 좀더 가깝다고 볼 수 있습니다.
const Component = () => {
let count = 0;
useEffect(() => {
count 에 1을 넣어서 날 다시 렌더해라 ();
console.log(count);
}, [];
이런 동작은 모든 렌더에 해당됩니다. react 는 모든 리렌더마다 컴포넌트라는 함수를 다시 실행합니다.
그러니 setState를 한 그 렌더에서는 (정상적인 방법으로는) 렌더 이후의 변경된 값에 접근할 수 없습니다.
const Component = () => {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(1); // state 를 1로 변경했지만
console.log(count); // 바로 출력해도 0
setTimeout(() => console.log(count), 1000); // 1초 이따 출력해도 0
setTimeout(() => console.log(count), 86400000); // 다음 날에 출력해도 0
}, [];
/* setCount(1) 로 인해 다음 리렌더가 발생함.
이건 위의 console.log 들과는 달리
count 가 1인 평행우주에 있기 때문에
1이 정상적으로 출력된다. */
console.log(count);
좀더 풀어서 확인해 볼까요?
// 첫번째 렌더: count 가 0인 우주에 있다.
useEffect(() => {
setCount(1); // state 를 1로 변경
console.log(count); // 0
setTimeout(() => console.log(count), 1000); // 0
setTimeout(() => console.log(count), 86400000); // 0
}, [];
console.log(count);
// 두번째 렌더: count 가 1인 우주에 있다.
useEffect(() => {
// 두 번째 렌더에서 이 4줄은 실행되지 않습니다.
setCount(1);
console.log(count);
setTimeout(() => console.log(count), 1000);
setTimeout(() => console.log(count), 86400000);
}, [];
console.log(count); // 1
앞의 구슬 예제를 다시 빌려오면, setState
는 Mike 가 (Component
) 들고 있는 구슬주머니 (count
) 에 구슬을 넣는 대신, Mike 를 거기 남겨둔 채로 저 멀리에 Mike가 구슬이 1개 들어있는 구슬주머니를 들고 있는 평행우주를 만들어 버립니다. 그러니까 당연히 원래의 Mike 가 있던 곳에서는 주머니를 아무리 살펴봐도, 하루가 지나도, 1년이 지나도 주머니에 구슬이 하나도 없습니다.
간혹 위와 같이 설명하지 않고 "비동기라서 바로 업데이트되지 않아요" 라고 설명하는 stackoverflow 답변들을 볼 수 있습니다.
setState
가 비동기로 동작하는 건 맞습니다. 하지만 그게 state 가 업데이트된 채로 콘솔에 찍히지 않는 이유는 아닙니다.
쓰다 보니, 이 섹션은 dan abramov 의 글과 유사한 흐름을 가지게 되었습니다. react 에 익숙하신 분이라면, 이 섹션 대신 위 글을 읽는 걸 추천드립니다.
또한 JavaScript에 익숙하지 않다면, 아래 글을 읽는 데에 조금 어려움이 있을 수 있습니다.
이 이야기는 큰 틀에서 왜 리액트 컴포넌트가 class 에서 function 으로 넘어왔는가에 대한 이야기로 연결됩니다.
먼저 옛날 얘기를 해 보면, 리액트 함수 컴포넌트는 2019년에 hooks가 등장하며 대중화되었습니다. 그 전까지 99.9%의 리액트 컴포넌트는 class 문법이었습니다. 놀랍게도, 그땐 이렇게 평행우주를 만드는 식으로 동작하지 않았습니다!
위의 예제를 그대로 class 컴포넌트로 옮기면 아래와 같습니다.
class Component extends React.Component {
state = { count: 0 };
componentDidMount() {
this.setState({ count: 1 }); // state 를 1로 변경
console.log(this.state.count); // 바로 출력하면 0
setTimeout(() => console.log(this.state.count), 1000); // 1초 이따 출력하면 1 😮
setTimeout(() => console.log(this.state.count), 86400000); // 다음 날에 출력하면 1 💁♂️
}
render() {}
}
잘 보면 위 예제에서도 바로 출력하면 0이긴 한데, 이건 setState
가 비동기로 동작하기 때문이 맞습니다. (그래서 간혹 stackoverflow 등에서 setState
가 비동기라서 바로 반영되지 않는다는 답변이 달리는 게 아닐까.. 하는 생각이 드네요)
아무튼 위 예제에서 볼 수 있듯, class component에서는 시간이 지나면 변경 이후의 state에 접근할 수 있는 여지가 충분히 있었습니다.
하지만 이런 방식에는 중요한 버그가 있습니다. 아래 예제를 볼까요?
class Component extends React.Component {
state = { count: 0 };
handleClick = () => {
this.setState({ count: this.state.count + 1 });
axios.post(`/api/visit/${this.state.count}`).then(() => {
window.alert(`${this.state.count} visited!`); // 얘가 문제입니다 ❌
})
}
render() {
return <button onClick={this.handleClick}>다음</button>
}
}
위 예제는 각 count 마다 서버에 api 콜을 날리는 예제입니다. 우리가 의도한 건 1을 방문하여 1 visited!
메세지를 alert 하고, 2 를 방문하여 2 visited!
를 alert 하고, ... 하는 컴포넌트였습니다.
하지만 다음 버튼을 매우 빠르게 누르거나 서버 응답이 느려서 실행 순서가 꼬이면 어떻게 될까요?
1 다음 2 다음 3이 떠야 하는데, 3이 세 번 뜨는 버그를 확인할 수 있습니다. 변경 후의 state에 접근할 수 있기 때문에 발생하는 버그입니다.
따라서 리액트는 변경 후의 state에 접근할 수 없도록 발전하게 되었습니다.
사실 위에서 이 글의 결론은 다 났습니다. 리액트는 위와 같은 버그를 피하기 위해 의도적으로 하나의 렌더 단계에서 변경 후의 state에 접근하지 못하게 디자인되었습니다.
궁금해하실 분들을 위해 뒷이야기를 좀 해보면, 자바스크립트의 클로저 기능을 이용해서 render
함수를 클로저로 만들고 모든 것을 render 함수 안에 몰아넣으면 아래와 같이 작성하는 게 가능합니다.
class Component extends React.Component {
state = { count: 0 };
render() {
const count = this.state.count;
const handleClick = () => {
this.setState({ count: count + 1 });
axios.post(`/api/visit/${count}`).then(() => {
window.alert(`${count} visited!`); // 이러면 버그는 없습니다 ✅
});
}
return <button onClick={this.handleClick}>다음</button>
}
}
이렇게 render 안에 다 몰아넣고 나니, "우리 class component 왜 써?" 라는 질문을 하게 됩니다. 저렇게 쓸 거면 render 함수만 만들어도 되니까요. (어째 코드가 익숙하죠? 네, 한 껍질 벗져서 render 만 쓰는 게 함수 컴포넌트입니다.)
그래서 함수 컴포넌트를 제대로 쓰기 위해 hooks가 등장했고, 이제는 class 때처럼 state에 대한 mutation 이니 클로져니 이런 복잡하고 어려운 걸 고민할 필요 없이 코드를 작성할 수 있게 되었습니다.
const Component = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
axios.post(`/api/visit/${count}`).then(() => {
window.alert(`${count} visited!`); // 이러면 버그는 없습니다 ✅
});
}
return <button onClick={this.handleClick}>다음</button>;
}