react state에 관하여

리액트의 state에 대해 알아보자

기본적인 스토리

export function App() {
  const [count, setCount] = React.useState(0);

  const handleClick = () => {
    setCount((prev) => prev + 1);
  };

  return (
    <>
      <div>{count}</div>
      <button onClick={handleClick}>증가</button>
    </>
  );
}

실행해보기

증가버튼을 클릭하면 count가 하나 증가하고 리렌더링됩니다.

initialstate는 갈아끼워지지 않는다

export function App() {
  const [parentCount, setCount] = React.useState(0);
  const handleClick = () => {
    setCount((prev) => prev + 1);
  };
  return <ChildComponent parentCount={parentCount} onClick={handleClick} />;
}

function ChildComponent({ parentCount, onClick }) {
  const [childCount, setChildCount] = React.useState(parentCount);

  return (
    <>
      <div>parentCount : {parentCount}</div>
      <div>childCount : {childCount}</div>
      <button onClick={onClick}>증가</button>
    </>
  );
}

실행해보기

증가 버튼을 눌러도 parentCount는 바뀌지만, childCount는 바뀌지 않습니다.

부모 컴포넌트가 re-render되서 자식요소에 props를 넘겨주고, 그 props를 받아 useState안에 initialState를 넣어도 childCount는 바뀌지 않습니다.

그 이유는 다음과 같다.

  1. useState에 처음에 넣은 0이 리액트가 관리하는 메모리 셀에 들어가 있기 때문입니다. 안에 이미 값이 있다면 initialState가 들어와도 교체하지 않습니다.
  2. 메모리 셀에 들어간 값은 setChildCount함수로만 업데이트 하는게 기본 약속이기 때문입니다.

다시 말해서, 처음만 빼고는 setChildCount로 업데이트를 해줘야 하기 때문에 아무리 부모컴포넌트에서 props를 useState에 넘겨준다 한들 값이 바뀌지 않습니다.

그러면 어떡하나?

import React, { useEffect } from "react";

export function App() {
  const [parentCount, setCount] = React.useState(0);
  const handleClick = () => {
    setCount((prev) => prev + 1);
  };
  return <ChildComponent parentCount={parentCount} onClick={handleClick} />;
}

function ChildComponent({ parentCount, onClick }) {
  const [childCount, setChildCount] = React.useState(parentCount);
  useEffect(() => {
    setChildCount(parentCount);
  }, [parentCount]);
  return (
    <>
      <div>parentCount : {parentCount}</div>
      <div>childCount : {childCount}</div>
      <button onClick={onClick}>증가</button>
    </>
  );
}

실행해보기

부모로부터 받은 props를 childCount로 쓰고 싶은 경우에는 useEffect를 써야합니다. 즉 parentCount가 바뀔때마다 setChildCount를 해주는 것입니다. hook은 이렇게 작동합니다.

그럼 만약에 initialState가 계속 적용되면 어떻게 될까? 이게 과연 문제가 될일인가?

아마도 childCount의 상태가 두곳에서 관리되기 때문에 어떤,,,,먼 미래에,,,사이드 이펙트가 발생할 지도 모르겠습니다.

함수도 받습니다

function getBigNumber() {
  let count = 0;
  for (let i = 0; i < 10 ** 9; i += 1) {
    count += 1;
  }
  return count;
}

export function App() {
  const bigNumber = getBigNumber();
  const [num, _] = React.useState(bigNumber);
  const [count, setCount] = React.useState(0);
  return (
    <>
      <div>{num}</div>
      <button onClick={() => setCount((prev) => prev + 1)}>리렌더트리거</button>
    </>
  );
}

실행해보기

리렌더트리거버튼을 누를때마다 getBigNumber는 계속 호출됩니다. 그런데 이것은 시간이 오래 걸려서 사용자 경험에도 안좋습니다. 이런 경우 useState안에 함수를 넣을 수 있습니다.

function getBigNumber() {
  let count = 0;
  for (let i = 0; i < 10 ** 9; i += 1) {
    count += 1;
  }
  return count;
}

export function App() {
  const [num, _] = React.useState(() => getBigNumber());
  const [count, setCount] = React.useState(0);
  return (
    <>
      <div>{num}</div>
      <button onClick={() => setCount((prev) => prev + 1)}>리렌더트리거</button>
    </>
  );
}

실행해보기

useState안에 함수 자체를 넣으면 처음에만 실행되고, 그 다음 렌더링때는 실행되지 않습니다. 아마도 함수가 들어오면 메모리셀에 값이 없는 경우에만 그 함수를 다시 실행시키는것 같습니다.

getBigNumber를 다시 실행시키는 방법은 없는걸까?

이런 방법이 있습니다.

function getBigNumber() {
  let count = 0;
  for (let i = 0; i < 10 ** 9; i += 1) {
    count += 1;
  }
  return count;
}

export function App() {
  const [myKey, setMyKey] = React.useState(1);
  return (
    <>
      <ChildComponent key={`mykey-${myKey}`} />
      <button onClick={() => setMyKey((prev) => prev + 1)}>다시그리자</button>
    </>
  );
}

export function ChildComponent() {
  const [num, _] = React.useState(() => getBigNumber());
  return <div>{num}</div>;
}

실행해보기

React는 key를 바탕으로 ReactNode를 비교합니다. 다시말해서 key를 바꿨기 때문에 다른 ReactNode로 취급되고 새로 마운트 되는 것입니다.

아무튼 핵심은 useState는 함수도 받는다는것입니다 (이것을 lazy initialization이라고 부른다)

setState의 함정

function doLongWork() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 1500);
  });
}

export function App() {
  const [count, setCount] = useState(0);
  const handleClick = async () => {
    await doLongWork();
    setCount(count + 1);
  };
  return (
    <>
      <div>{count}</div>
      <button onClick={() => handleClick()}>증가</button>
    </>
  );
}

실행해보기

증가 버튼을 한번 클릭하고 -> 2초 기다렸다가… -> 한번 다시 클릭하고 -> 2초 기다렸다가… -> 보면 카운트가 2가 됩니다.

그런데 버튼을 2번 연속 클릭하면 1만 증가합니다. 그 이유는 다음과 같습니다.

  1. setCount를 호출하면 re-render됩니다. 다시 말해서 함수가 다시 호출됩니다.
  2. 함수가 호출될때 setCount에 넣은 count는 현재 그 함수가 호출될때 바라보는 count입니다. 그리고 바로 연속으로 한번 더 누르면? 그때 호출되는 setCount가 바라보는 count는 이전의 count가 아닌 새로운 count, 즉 아직 0입니다.

핵심은 handleClick함수가 실행되는 시점에 count가 무엇이냐 하는 것입니다.

그래서 어떻게 해야 이 문제를 해결할까? (엄밀히는 문제는 아닙니다. 그냥 그렇게 작동할 뿐!)

function doLongWork() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 1500);
  });
}

export function App() {
  const [count, setCount] = useState(0);
  const handleClick = async () => {
    await doLongWork();
    setCount((prev) => prev + 1);
  };
  return (
    <>
      <div>{count}</div>
      <button onClick={() => handleClick()}>증가</button>
    </>
  );
}

실행해보기

setCount((prev) => prev + 1); 이렇게 이전 값을 활용 해야 합니다. 이게 작동하는 원리는 다음과 같지 않을까…하고 추측을 해봅니다.

먼저 심플하게 요약하면, (prev) => prev + 1 이 함수의 첫번째 인자값으로 메모리 셀에 있는 최신값을 넣어주고 prev + 1을 메모리 셀에 동기적으로 업데이트 합니다. 그래서 count도 최신 값을 유지하는 것입니다.

  1. 증가 버튼 클릭
  2. 브라우저가 이벤트를 전파
  3. button에 event dispatch (엄밀히는 bubbling이 발생하면서 root element에 도달하면 React의 Event Delegation패턴으로 걸어놓은 내 클릭 이벤트가 호출됨)
  4. onClick함수 호출 -> handleClick함수 호출됨
  5. await에서 1.5초 기다리고 있는 와중에…
  6. 나는 다시 증가 버튼 클릭
  7. 2 ~ 4과정이 다시 실행됨
  8. 이제 1.5초가 지나서 setCount가 호출되고, (예를들어)0.05초 후에 다시 setCount가 호출된다
  9. React는 내부적으로 큐에 State를 업데이트 하는 일(?)을 담는데 지금 2개 담겨 있는 상태이다
  10. 하나씩 큐에 있는 일을 처리하는데,,,하나 처리하고 메모리 셀에 있는 값을 업데이트 한다
  11. 여기서! prev => prev + 1 요기의 prev에 메모리 셀에 있는 값을 넣는다. 그리고 그 결과 값(prev + 1)을 메모리셀에 다시 동기적으로 업데이트 한다. 그래서 다음 setCount(prev => prev + 1)가 실행될때 prev에는 메모리 셀에 있는(방금 업데이트한) 값 (= 최신값) 이 들어간다.

언제나 다시 그려지는건 아니다

function App() {
  console.log("rendering...");
  const [count, setCount] = React.useState(0);
  const handleClick = () => {
    setCount((prev) => prev);
  };
  return <button onClick={handleClick}>{count}</button>;
}

실행해보기

setCount를 호출하기만 하면 리렌더링 될것 같지만, 꼭 그런건 아닙니다!

내부적으로 setCount에 들어온 값을 Object.is 를 사용해 전 상태와 비교해서 달라졌으면 리렌더링합니다. 달라지지 않았다면? 리렌더링 하지 않습니다.

참고로 여기서 말하는 리렌더링이란, Component를 RealDOM에서 unmount시키고 다시 mount시키는 것이 아니라, Functional Component를 다시 호출하는것을 의미합니다.

동기적으로 리렌더링

React는 setState를 비동기적으로 처리합니다. setState하자마자 바로 re-render되는것이 아니라 queue에 담아놨다가 state update를 한번에 샤라락 하고 re-render는 한번만 해줍니다.

export function App() {
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    console.log("count in useEffect : ", count);
  }, [count]);
  const handleClick = () => {
    setCount((prev) => prev + 1);
    console.log("handleClick");
    setCount((prev) => prev - 1);
  };
  return (
    <>
      <div>{count}</div>
      <button onClick={handleClick}>Click Me</button>
    </>
  );
}

실행해보기

count를 dependency로 하는 useEffect안에 넣어준 함수는 실행되지 않습니다. 즉, setCount((prev) => prev + 1); 를 하자마자 re-render가 일어나는게 아니라 setCount((prev) => prev - 1); 이거까지 다 처리되고 나서 re-render가 일어납니다. 그렇기 때문에 count는 0 에서 0으로 변했고 console.log("count in useEffect : ", count);는 실행되지 않습니다.

그런데 만약에 setCount를 호출한 직후에 렌더링을 강제하고 싶다면? 이때 바로 flushSync를 사용하면 됩니다.

export function App() {
  console.log("re-render!");
  const [count, setCount] = React.useState(0);
  const handleClick = () => {
    console.log("before flushSync");
    flushSync(() => {
      console.log("before setCount");
      setCount((prev) => prev + 1);
      console.log("after setCount");
    });
    console.log("after flushSync");
    const countBox = document.querySelector("#count-box");
    console.log("count of state : ", count);
    console.log("count in element : ", countBox.textContent);
  };
  return (
    <>
      <div id="count-box">{count}</div>
      <button onClick={handleClick}>증가</button>
    </>
  );
}

실행해보기

증가 버튼을 클릭하면 다음과 같은 순서로 로그가 찍힙니다

  1. before flushSync
  2. before setCount
  3. after setCount
  4. re-render!
  5. after flushSync
  6. count of state : 0
  7. count in element : 1

여기서 눈여겨 볼만 한것은 두가지입니다.

첫번째는, flushSync이후에 DOM이 바로 업데이트 되었다는 것입니다. 이것이 flushSync의 용도입니다. DOM에 바로 반영이 되기 때문에 setState이후 DOM조작을 할때 유용할것 같습니다.

두번째는, 함수 컴포넌트 안에 있는 count는 아직 0이라는 것입니다. flushSync의 이름이 무시무시하지만 하나의 함수일 뿐이고 이 함수에서 어떤 일을 하던간에, flushSync가 호출된 직후 실행될 console.log("count of state : ", count); 이 시점에서의 count는 flushSync 호출 전 후와 같습니다. 그래서 4번에 re-render가 되었지만 그것이 이미 실행중인 함수의 count(이전 상태)의 값을 바꾸지는 않습니다. 당연한 말인데 설명하려니까 말이 길어지네요..

function doSomething() {
  const newCount = 1;
  justFunction(newCount);
}

function justFunction(newCount) {
  const count = newCount;
  if (count === 0) {
    doSomething();
    console.log(count);
  }
}

justFunction(0);

실행해보기

정확하진 않지만 이런 느낌인듯 싶습니다. count는 0입니다!

이상입니다. 읽어주셔서 감사합니다 😀

Leave a Reply

Your email address will not be published.