setState의 동작

박종대·2022년 9월 26일
0

React

목록 보기
1/2

setState의 동작

배경

SQLExecute한 결과의 ResultSet을 출력하는 로직 작성

개발

3 개의 쿼리를 작성하고 실행하면

3 개의 response가 3개의 결과탭으로 나타나야 합니다.

해당 쿼리들은 수행 시간이 오래 걸리지 않습니다. 문제도 발생하지 않습니다. 다만 쿼리 수행 시간이 오래 걸리는 쿼리에 대해서는 이야기가 달라집니다.

해당 쿼리는 수행하는데 2초 정도 소요 됩니다. 이 쿼리를 작성하고 Run 버튼을 여러번 클릭하면 어떻게 될까요?

사용자가 원하는, 개발자가 예상하는 시나리오는 내가 버튼을 클릭한 횟수 만큼 요청이 갔을 테니 그 횟수 만큼 응답도 올 것이다. 라고 생각하게 될 겁니다.

하지만 실제 실행 시에 버튼을 클릭한 횟수보다 적게 응답이 왔습니다. 왜일까요?

결론부터 말씀드리면 setState의 비동기 동작 때문입니다. 현재 구조에서 수행 결과인 response를 state로 관리하고 있는데 첫 번째 버튼 클릭한 setResponse가 response state에 반영되기도 전에 다음 setResponse가 호출되기 때문입니다.

아직 무슨 말인지 잘 모르겠습니다. setState 동작에 공부해야 할 것 같습니다.

setState의 동작

import { useState } from 'react';

export const App: React.FC = () => {
  const [index, setIndex] = useState<number>(0);
  console.log(`rerendering${index}`);

  const onClickHandler = () => {
      setIndex(index + 1);
  }
  
  return(
      <div>
          <div>
              {index}
          </div>
          <button onClick={onClickHandler}>+1 Up</button>
      </div>
  )
}

export default App;

버튼 클릭시 index state를 1 증가 시켜주는 프로그램입니다. 버튼을 클릭하면 리렌더링이 한 번 일어날 것입니다. 확인해보겠습니다.

리렌더링이 한 번 일어날 것 같지만 리렌더링이 두 번씩 일어납니다. 이는 index.tsx의 React.StrictMode 때문입니다. 일단 setState 동작에 주목할 것이므로 정상적으로 동작한 것(리렌더링이 한 번 일어난 것)으로 간주하겠습니다.

이번에는 setIndex를 연속으로 두 번 호출해보겠습니다.

import { useState } from 'react';

export const App: React.FC = () => {
  const [index, setIndex] = useState<number>(0);
  console.log(`rerendering${index}`);

  const onClickHandler = () => {
      setIndex(index + 1);
      setIndex(index + 1);
  }

  return(
      <div>
          <div>
              {index}
          </div>
          <button onClick={onClickHandler}>+2 Up</button>
      </div>
  )
}

export default App;

이제는 숫자가 2씩 증가해야 할 것 같고 리렌더링이 2 + 1 = 3번 일어나야 할 것 같습니다. 결과를 보겠습니다.

결과에 변화가 없습니다. 두 번 클릭하니 index는 2만 증가했고 리렌더링도 기존과 같이 2번씩만 일어납니다. 각 현상의 원인에 대해 분석 해 봐야겠습니다.

  • setIndex(index + 1)을 두 번 호출했는데 index가 1씩만 증가하는 이유?

setState는 비동기로 동작한다는 중요한 특성을 가지고 있습니다. 따라서 첫번째 setState 호출에 대한 결과가 반영되기도 전에 두 번째 setState가 호출되기 때문에 이전 호출 결과를 사용하지 못합니다. 그래서 setIndex(index + 1)을 2~3번 호출해도 index는 1씩만 증가하는 것처럼 보이게 됩니다.

  • 리렌더링 횟수에는 왜 변화가 없는가?

setIndex(index + 1) 호출 후 리렌더링 → setIndex(index + 1) 호출 후 리렌더링의 과정을 거쳐 리렌더링 횟수도 1회 증가해야만 할 것 같은데 그렇지 않습니다.

setState는 연속적으로 호출했을 때 리액트 내부적으로 batch 처리를 합니다. 한번에 실행한다는 의미입니다. 조금 더 자세히 말씀드리면 setState가 연속적으로 호출되면 각 함수 호출 시 전달받은 각각의 state를 merge하는 작업을 거칩니다. 그 후 state에 반영하는 setState는 한 번만 호출하게 됩니다.

그럼 위 프로그램에서 동작은 어떻게 될까요?

setIndex(index + 1); → index + 1 = 1

setIndex(index + 1); → index + 1 = 1 (아직 위의 setIndex 결과가 반영되기 전의 index 변수를 사용하므로)

1이라는 값을 index state 객체에 merge 하게 되니 그냥 setIndex(1)이 한 번 호출 되는 것입니다. 그래서 결국 리렌더링 횟수도 증가하지 않습니다.

setState의 인자

이 문제의 해결책은 setState의 인자로 updater callback function을 넘겨주는 방식입니다. 해당 updater function에서는 이전 state 값에 접근할 수 있습니다. 코드를 수정해보겠습니다.

import { useState } from 'react';

export const App: React.FC = () => {
  const [index, setIndex] = useState<number>(0);
  console.log(`rerendering${index}`);

  const onClickHandler = () => {
      setIndex(index => index + 1);
      setIndex(index => index + 1);
  }

  return(
      <div>
          <div>
              {index}
          </div>
          <button onClick={onClickHandler}>+2 Up</button>
      </div>
  )
}

export default App;

두 번 클릭했더니 2+2=4 가 증가했고 리렌더링 횟수에는 역시 변화 없습니다. 연속된 setState는 일괄처리 되기 때문입니다.

이 updater 함수에 대해 좀 더 자세히 보겠습니다. React 공식 문서에 언급되어 있는 내용입니다.

setState()는 컴포넌트를 항상 즉각적으로 갱신하지는 않습니다. 오히려 여러 변경 사항과 함께 일괄적으로 갱신하거나, 나중으로 미룰 수도 있습니다. 이로 인하여 setState()를 호출하자마자 this.state에 접근하는 것이 잠재적인 문제가 될 수 있습니다. 그 대신에 componentDidUpdate 또는 setState의 콜백(setState(updater, callback))을 사용하세요. 둘 다 갱신이 적용된 뒤에 실행되는 것이 보장됩니다. 이전 state 값을 기준으로 state 값을 설정해야 한다면, 아래에 설명된 updater 인자에 대한 내용을 읽어보세요.

… 중략

첫번째 인자 updater는 다음과 같은 형태를 가지는 함수입니다.

**(state, props) => stateChange**

state가 갱신이 된 뒤 실행되는 것을 보장시켜 주기 때문에 가능한 일이었습니다. 이제 이전 state값에 접근할 수 있으니 이를 가공하여 새로운 state를 리턴 해주는 것이 가능해졌습니다. 또 한가지 특이한 점은 props도 사용할 수 있다는 점입니다.

해결

import { useState } from 'react';

export const App: React.FC = () => {
  const [responseList, setResponseList] = useState<string[]>([]);

  const onClickHandler = () => {
    console.log('click event!!');
    new Promise((resolve, reject) => {
        setTimeout(() => resolve('success!'), 3000);
    })
        .then((response) => setResponseList([...responseList, String(response)]));
  }

  return(
      <div>
          <div style={{ display: 'flex' }}>
              {responseList.map((response) => <div style={{ marginLeft: '5rem' }}>{response}</div>)}
          </div>
          <button onClick={onClickHandler}>Add Response</button>
      </div>
  )
}

export default App;

실행하는데 3초가 걸리는 쿼리를 재현하기 위해서 3초뒤에 ‘success’라는 string을 resolve하는 Promise 객체를 생성하여 responseList를 update 해주는 프로그램입니다. 현재 updater callback function을 사용하지 않았습니다. 위에서 한 번 살펴봤으니 뭔가 정상적으로 동작하지 않을 거라는 느낌이 옵니다. 우리가 원하는 것은 클릭한 만큼 response가 나타나야 합니다. 이에 주목하여 결과를 보시면 됩니다.

클릭 이벤트는 2번 발생했지만 success는 하나만 추가된 것을 확인할 수 있습니다. 코드를 수정해보겠습니다!

import { useState } from 'react';

export const App: React.FC = () => {
  const [responseList, setResponseList] = useState<string[]>([]);

  const onClickHandler = () => {
    console.log('click event!!');
    new Promise((resolve, reject) => {
        setTimeout(() => resolve('success!'), 3000);
    })
        .then((response) => setResponseList(responseList => [...responseList, String(response)]));
  }

  return(
      <div>
          <div style={{ display: 'flex' }}>
              {responseList.map((response) => <div style={{ marginLeft: '5rem' }}>{response}</div>)}
          </div>
          <button onClick={onClickHandler}>Add Response</button>
      </div>
  )
}

export default App;

updater callback function을 사용해서 이전 state에 접근하여 새로운 state를 return 하는 방식으로 변경하였습니다!

click event 2번에 success 2번이 정상적으로 추가되었습니다.

요약

  • setState는 비동기로 동작한다.
  • setState(newState) 호출 직후 state에 접근하면 반영이 되어 있지 않을 것이다.
  • setState를 연속적으로 호출해도 일괄 처리 되기 때문에 결국 리렌더링은 한 번만 일어난다.
  • setState에는 updater callback function을 인자로 사용할 수 있고 updater에서는 prev state와 props를 사용하여 새로운 state를 리턴할 수 있다.

++ 추가

In React 17 and earlier, only updates inside React event handlers are batched by default. There is an unstable API to force batching outside of event handlers for rare cases when you need it.

Starting from React 18, React batches all updates by default. Note that React will never batch updates from two different intentional events (like clicks or typing) so, for example, two different button clicks will never get batched. In the rare cases that batching is not desirable, you can use [flushSync](https://reactjs.org/docs/react-dom.html#flushsync).

React18에서는 모든 이벤트를 배치 처리하고 이전 버전에서는 React event handler 내에서만 배치 처리를 한다는 내용입니다. React 공식 답변에서 가져 왔습니다.

https://stackoverflow.com/questions/48563650/does-react-keep-the-order-for-state-updates/48610973#48610973

profile
Frontend Developer

0개의 댓글