useState 조금 더 잘 쓰기

se-een·2023년 5월 11일
12

React 탐구하기

목록 보기
2/7
post-thumbnail

지난 편에서 useState의 setter가 비동기적으로 동작할 수 밖에 없는 이유에 대해서 살펴보았습니다.

이번 글에서는 비동기적으로 동작하는 useState를 사용할 때 주의할 점과 조금 더 잘 쓰는 방법에 대해서 정리해보겠습니다. 😃

setter를 사용하는 두 가지 방식

useState의 setter를 사용하실 때 아래의 코드와 같이 크게 두 가지 방식을 접했을 것이라고 생각합니다.

const [count, setCount] = useState(0);

// 첫 번째 방식
setCount(count + 1);

// 두 번째 방식
setCount((prevCount) => prevCount + 1);

이 두 방식의 큰 차이점은 바로 이전 state를 기반으로 state를 업데이트 하는가 입니다. 다음의 코드에서 버튼을 한 번 클릭했을 때 count는 무슨 값을 가질까요?

const [count, setCount] = useState(0);

const handleSetCount = () => {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
};

return <button onClick={handleSetCount} />

바로 1 입니다. setCount(count + 1)을 세 번 했으니 +3이 될 것이라는 기대와는 달리, count 변수값은 1로 업데이트 됩니다.

위 코드의 handleSetCount의 동작을 해석해보면 다음과 같습니다.

const [count, setCount] = useState(0);

const handleSetCount = () => {
  setCount(0 + 1);
  setCount(0 + 1);
  setCount(0 + 1);
};

return <button onClick={handleSetCount} />

0 더하기 1을 세 번 호출한 것과 같은 의미입니다. React는 state 업데이트를 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다리기에 state 업데이트(리렌더링)는 모든 setter 호출이 완료된 이후에만 일어나게 됩니다.

즉, setter를 호출해도 이미 실행 중인 코드에서 count 변수의 state가 업데이트 되지않기 때문에 위와 같은 결과를 보이는 것입니다.

따라서 버튼을 클릭했을 때 count + 3의 기능을 기대한다면 다음과 같이 업데이터 함수setCount에 전달해야합니다.

const [count, setCount] = useState(0);

const handleSetCount = () => {
  setCount((prevCount) => prevCount + 1);
  setCount((prevCount) => prevCount + 1);
  setCount((prevCount) => prevCount + 1);
};

return <button onClick={handleSetCount} />

업데이터 함수는 (prevCount) => prevCount + 1 부분인데요. 쉽게 설명하면 count 변수의 이전 state 값을 가져와서 state를 업데이트 해주는 역할입니다.

그래서 위 코드는 다음과 같이 해석해볼 수 있겠습니다.

const [count, setCount] = useState(0);

const handleSetCount = () => {
  setCount((0) => 0 + 1);
  setCount((1) => 1 + 1);
  setCount((2) => 2 + 1);
};

return <button onClick={handleSetCount} />

왜 이렇게 동작할까요? 이는 React가 state를 스냅샷처럼 관리하기 때문입니다.

state를 스냅샷으로 관리

useState의 setter를 호출하면 React에게 (리)렌더링 요청을 보내는 것은 다들 알고 계실 것입니다.

여기서 렌더링이란 React가 컴포넌트, 즉 함수를 호출한다는 뜻이고 해당 함수에서 반환하는 JSX는 시간상 UI의 스냅샷과 같습니다. 그래서 prop, 이벤트 핸들러, 로컬 변수는 모두 렌더링 당시의 state를 사용해 계산됩니다.

이게 무슨 뜻인지 코드로 알아보겠습니다.

const App = () => {
  const [count, setCount] = useState(0);

  const handleSetCount = () => {
    setCount(count + 1);

    console.log('first : ' + count); // ?

    setTimeout(() => {
      alert(count); // ?
    }, 3000);
  };

  console.log('second : ' + count); // ?

  return <button onClick={handleSetCount}>{count}</button> // ?
}

위 코드에서 버튼을 클릭한다면 콘솔, alert 메세지, 버튼 Text 값에 각각 무슨 값이 찍힐 것 같나요?

결과는 다음과 같습니다.

앞서 React는 state를 업데이트 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다린다고 하였습니다.

따라서 first 콘솔과 alert 메세지는 setCount가 되기 이전, 즉 현재의 스냅샷으로 고정된 state를 기반으로 로직을 수행합니다.

이벤트 핸들러의 모든 코드가 끝나면 count의 state를 업데이트 합니다. 이 과정에서 React는 App 컴포넌트(함수)를 다시 실행시킵니다.

App 컴포넌트는 count state 값을 사용해 계산된 새로운 props와 이벤트 핸들러가 포함된 UI의 스냅샷을 JSX에 반환하죠.

이를 React가 받아서 변경사항을 확인하고 화면을 업데이트 하는 과정을 거칩니다. 그래서 second 콘솔과 브라우저 화면에는 업데이트 된 count 값인 1이 찍히는 것입니다.

이 부분은 공식문서에도 잘 정리되어있습니다.

초기 state를 다시 생성하지 않기

useState를 선언할 때 소괄호 값에 넣는 값이 초기 state 입니다. 이 초기값은 첫 렌더링 이후에 발생하는 리렌더링 때는 무시됩니다.

간혹 초기값으로 함수의 반환값을 넣어줄 때가 있습니다. 코드 예시를 보시죠.

const getData = () => {
  console.log('getData'); // ?
  return 0;
};

const App = () => {
  const [count, setCount] = useState(getData());

  return <button onClick={() => { setCount(count + 1) }}>button</button>
}

버튼을 클릭하면 getData 함수가 계속 재실행될까요?

버튼을 누를 때마다 getData 함수가 계속 호출되고 있는 것을 볼 수 있습니다. useState의 초기값은 리렌더링 때 무시된다는 말이 소괄호 내부 로직 자체를 실행하지 않는 것이 아니라, 소괄호 내부의 값을 무시한다는 뜻입니다.

따라서 이는 자원을 낭비하는 비효율적인 동작입니다. 위 코드 같은 경우는 큰 문제는 없겠지만, 만약 getData 함수가 실행하는데 1초정도 걸리는 함수였다면 리렌더링에 문제가 있을 수도 있겠죠.

어떻게 개선해볼 수 있을까요? 다음과 같이 useMemo 훅을 통해서 메모제이션을 하는 방법도 있을 것입니다.

const getData = () => {
  console.log('getData');
  return 0;
};

const App = () => {
  const [count, setCount] = useState(useMemo(() => getData(), []));

  return <button onClick={() => { setCount(count + 1) }}>button</button>
}

이제 getData 함수는 단 한 번만 실행됩니다.

하지만 useState 자체에서 제공하는 더 나은 방법이 있습니다.

바로 함수 자체를 전달하는 것입니다. 다음과 같이 말이죠.

const getData = () => {
  console.log('getData');
  return 0;
};

const App = () => {
  const [count, setCount] = useState(getData);

  return <button onClick={() => { setCount(count + 1) }}>button</button>
}

이 역시 버튼을 계속 누르더라도 getData 함수가 단 한 번만 찍히는 것을 확인할 수 있습니다.

key 속성으로 업데이트하기

보통 React에서 List를 map해서 렌더링할 때, 이 항목이 고유한 항목임을 나타내기 위해 key 속성을 부여할 것입니다.

리액트는 key 값이 변경되면 컴포넌트가 변경사항이 발생했다고 판단하기 때문에 리렌더링을 진행합니다.

이 특성을 이용하여 key 속성을 리렌더링을 하는 용도로도 사용이 가능합니다.

다음 코드를 보시죠.

const App = () => {
  const [count, setCount] = useState(0);

  const handleReset = () => {
    setCount(count + 1);
  }

  return (
    <>
      <button onClick={handleReset}>Reset</button>
      <Greeting key={count} />
      <p>reset count : {count}</p>
    </>
  );
}

const Greeting = () => {
  const [greetingMessage, setGreetingMessage] = useState('hello');
  
  return (
    <>
      <input
        value={greetingMessage}
        onChange={(e) => setGreetingMessage(e.target.value)}
      />
      <p>{greetingMessage}</p>
    </>
  );
}

화면에 reset 버튼과 input 태그로 입력한 인삿말을 보여주는 간단한 컴포넌트입니다. 특이한 점은 reset 버튼을 누르면 count 값을 1씩 올려주고 Greeting 컴포넌트의 key 속성으로 count를 할당해주고 있습니다.

setCount를 통해 count 값이 변경되면 key 값이 변경되서 Greeting 컴포넌트 자체를 리렌더링하게 됩니다.

만약 key 속성을 이용하지 않았다면 greetingMessage state를 App 컴포넌트에 선언해주고 이를 Greeting 컴포넌트에 props로 state와 setter를 전달해주었을 것입니다. 추가로 Reset 하는 로직도 추가로 만들 필요가 있겠죠.

Object.is와 state 업데이트

state를 객체나 배열로 관리할 때 state를 직접 변경하고 set하지 말라는 글을 한 번쯤 보신적이 있을 것 같습니다. array.push(), obj.a = 1과 같은 로직이 state를 직접 변경(변이)하는 것에 해당합니다.

왜 직접 변경하지 말라는 것일까요?

useState의 setter로 state를 업데이트 해주면 React는 Object.is를 사용해 이전 state와 다음 state를 비교합니다. 이전 state와 다음 state가 동일하면 리렌더링을 진행하지 않습니다.

객체나 배열을 직접 변경하고 set 하는 행위는 다음과 같을 것입니다.

Object.is의 결과값이 true이기에 React는 이전 state와 다음 state가 동일한 것으로 판단하여 리렌더링이 발생하지 않는 것이죠.

그와 반대로 스프레드 연산자를 통해 새로운 객체로 할당해준 후 set을 해주면 다음과 같은 결과가 나옵니다.

따라서 리렌더링이 진행됩니다.

객체를 직접 수정해서 할당하는 것과 새로운 객체로 할당해주는 것의 차이로 왜 Object.is 결과가 달리 나오는지 잘 모르겠다면 객체가 참조형 변수임을, 객체가 메모리에 어떻게 저장되는지를 살펴보면 좋을 것 같네요. 😀

또한 이 글에서 작성한 내용 외에도 이전 렌더링에서 얻은 값을 저장하는 방법 등 다양한 방법들이 있으니 더 자세한 내용은 공식문서를 통해 알아보시면 좋겠습니다. 😀

profile
woowacourse 5th FE

5개의 댓글

comment-user-thumbnail
2023년 5월 11일

글 잘 읽었습니다~ 다음 시리즈도 기대할게요~

1개의 답글
comment-user-thumbnail
2023년 5월 15일

인세인~

1개의 답글
comment-user-thumbnail
2023년 7월 7일

글 잘 보았습니다~ 리엑트에 대해 이해를 엄청 잘하고 계신 것 같아 부러워요

답글 달기