본문 바로가기

react

[react] useState에 함수를 저장하면 생길 수 있는 문제 (반복 실행, useRef의 경우)

문제 상황

업무 중 모달 컴포넌트를 만들면서 다음 요구사항이 주어졌다.

  1. 모달 컴포넌트 우측 상단에 X 버튼을 넣어서 모달창을 끌 수 있게 해주세요.
  2. 경우에 따라, X 버튼을 눌러도 모달창이 꺼지지 않도록 해주세요.

요구사항1을 위해서 X버튼에 모달창을 끄는 이벤트 핸들러를 달아야 한다.

요구사항2를 위해서 이벤트 핸들러를 동적으로 변경하고 모달창을 리렌더링해야 한다.

따라서 아래처럼 이벤트 핸들러를 useState로 저장하고 동적으로 변경하려고 했다..

import React, { useState } from "react";

export default function Modal1() {
  const [callback1, setCallback1] = useState(() =>
    console.log("모달창을 끄는 콜백함수1")
  );

  //   모달 창 내 다른 프로세스
  const [otherProcess, setOtherProcess] = useState(0);

  return (
    <div>
      {/* 이벤트 핸들러 호출 */}
      <button className="btn_call_callback1" onClick={() => callback1()}>
        call callback
      </button>
      {/* 이벤트 핸들러 변경 */}
      <button
        onClick={() =>
          setCallback1(() =>
            console.log("경우에 따라 모달창을 끄지 않는 콜백함수2")
          )
        }
      >
        change Callback
      </button>

      {/* 모달 창 내 다른 작업 */}
      <p>{otherProcess}</p>
      <button onClick={() => setOtherProcess((prev) => prev + 1)}>
        do otherProcess
      </button>
    </div>
  );
}

 

그런데, 문제가 발생. 코드가 예상대로 동작하지 않았다.

  • Modal1의 최초 렌더링 시 callback1 함수가 자동으로 실행되었다. (함수를 넣어놓고 실행도 안했는데, 실행된 것)
  • 버튼.btn_call_callback1 을 눌렀을 때 다음 에러가 뜬다.
    • Uncaught TypeError: callback1 is not a function
    • callback1 에 분명 함수를 넣었는데, 왜 함수가 아니라고 하는가.

 

원인 파악: 초기 값에 함수를 담으면 실행 후 반환 값이 저장된다

callback1 함수가 자동 실행된 것으로 보아, useState(initialState)의 초기 인자 initialState는 함수를 입력할 경우, 자동 실행되어 반환 값이 초기 상태로 저장된다.

공식 문서에서도 다음 같이 말한다.

" 함수를 initialState로 전달하면 이를 초기화 함수로 취급합니다. (...) React는 컴포넌트를 초기화할 때 초기화 함수를 호출하고, 그 반환값을 초기 state로 저장합니다."

 

react 라이브러리를 열어 useState의 코드를 살펴보니 더 명확했다.

아래 "중요!!" 라고 된 주석에 보면, initailState가 함수이면 initailState를 실행하여 반환 값을 저장하였다.

//packages\react-reconciler\src\ReactFiberHooks.js

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  
  
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
  
  
  
  // 중요!!
  // initialState가 함수이면 실행하여 반환 값을 저장
  initialState = initialStateInitializer();
    if (shouldDoubleInvokeUserFnsInHooksDEV) {
      setIsStrictModeForDevtools(true);
      try {
        // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
        initialStateInitializer();
      } finally {
        setIsStrictModeForDevtools(false);
      }
    }
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

 

대안

함수를 useState로 관리하려면 initialState를  "함수를 반환하는 함수"로 저장해야 한다.

아까 예시에서는 이거를

  const [callback1, setCallback1] = useState(
  // 문제 부분
  () => console.log("모달창을 끄는 콜백함수1")
  );

////

  <button
        onClick={() =>
          setCallback1(
            () => console.log("경우에 따라 모달창을 끄지 않는 콜백함수2")
          )
        }
      >
        change Callback
      </button>

 

아래처럼 바꾼다.

  const [callback1, setCallback1] = useState(
  // 변경 : 함수를 반환하는 함수
    () => () => console.log("모달창을 끄는 콜백함수1")
  );

////

  <button
        onClick={() =>
          setCallback1(
            () => () => console.log("경우에 따라 모달창을 끄지 않는 콜백함수2")
          )
        }
      >
        change Callback
      </button>

 

중간정리
1. useState(initialState)에 함수를 넣어주면 최초 렌더링 시, 해당 함수를 실행하여 반환 값을 저장한다.
2. 어떤 함수를 state로 저장하려면 함수를 반환하는 함수 형태로 저장하고 반환 값인 함수를 사용한다.  

 

useState의 초기 값에 함수 반환 값을 저장하는 경우

아래 코드는 useState의 초기 값으로 callculateValue라는 함수의 반환 값을 저장한다.

그리고 다른 작업을 통해 컴포넌트가 리렌더링되면 useState의 값은 어떻게 될까

 

import { useState } from "react";

const calculateValue = () => {
  const value = Math.random().toFixed(2);
  console.log("연산작업 실행", value);

  return value;
};

export default function Case2() {
  const [calculateValue, _] = useState(calculateValue());

  //   모달 창 내 다른 프로세스
  const [otherProcess, setOtherProcess] = useState(0);

  return (
    <div>
      <p>{calculateValue}</p>

      {/* 모달 창 내 다른 작업 */}
      <p>{otherProcess}</p>
      <button onClick={() => setOtherProcess((prev) => prev + 1)}>
        do otherProcess
      </button>
    </div>
  );
}

do otherProcess 버튼을 눌러서 리렌더링을 일으키자.

그러면, calulateValue는 변하지 않는다. useState의 초기 값은 컴포넌트의 초기화 때에만 실행되므로 당연해보인다.

문제는 매 렌더링 마다 calculateValue()는 계속 호출된다.

위 사례로 보면   console.log("연산작업 실행", value); 이 계속 찍힌다.

 

따라서 useState의 초기 값인 함수 호출이 로직이 복잡하거나 시간이 오래걸리는 경우 성능 문제가 될 수 있다

 

원인

공식문서(https://ko.react.dev/reference/react/useState#avoiding-recreating-the-initial-state)에 따르면,
useState(initialState)는 인자인 initialState을 초기 값으로만 사용한다.

그럼에도 불구하고 매 렌더링마다 initialState의 라인을 다시 실행한다.

즉, 매 렌더링마다 실행은 하는데, 초기 값으로 렌더링되는 건 최초의 렌더링 때 뿐이다.

따라서 initialState가 함수의 호출이라면 매번 이것이 실행되는 것이다.

이 부분이 헷갈린다면 이렇게 코드를 바꾸면 이해가 될 것이다.

export default function Case2() {
  // 매 렌더링 마다 이 호출은 사용됨
  const initValue = callculateValue();
  
  // 하지만, useState는 인자인 초기 값을 최초 렌더링 때에만 사용
  const [calulateValue, _] = useState(initValue);
  // const [calulateValue, _] = useState(callculateValue());

  //   모달 창 내 다른 프로세스
  const [otherProcess, setOtherProcess] = useState(0);

/// 
}

 

대안

불필요하게 계속 함수가 호출되지 않도록 하려면 어떻게 해야 할까.

아까 문제1에서는 "useState(initialValue)가 함수이면 실행하여 반환 값을 초기 값으로 저장한다" 라고 하였다.

따라서, 함수 반환 값을 저장하려면 함수 선언을 initialValue로 저장하면 된다.

  const [calulateValue, _] = useState(callculateValue);
  // const [calulateValue, _] = useState(callculateValue());

 

결론

1. useState(initialState)에 함수를 넣어주면 최초 렌더링 시, 해당 함수를 실행하여 반환 값을 저장한다.
2. 어떤 함수를 state로 저장하려면 함수를 반환하는 함수 형태로 저장하고 반환 값인 함수를 사용한다.  
3. useState(initialState)에 함수의 호출을 넣으면, 최초 렌더링 시 해당 함수의 반환 값을 사용하지만 매 렌더링 마다 함수가 호출된다. 따라서, 반복적인 함수 호출을 막으려면 1의 법칙을 이용하여 initialState에 함수를 저장하면 된다.

 

 

그러면 useRef에 함수를 저장하는 경우는 어떨까?


함수가 state를 참조하는 경우에는 useState를 통한 함수 관리가 필요하겠지만 그렇지 않은 경우, 그리고 일부 특수한 케이스에서는 useRef에 함수를 담아서 관리한다.(이 부분은 나중에 더 자세히 다뤄보겠다...)

useRef에 함수를 담아 관리하는 경우
1. 이벤트 핸들러가 최신 상태를 유지해야 하지만, 리렌더링은 피해야 하는 경우
useRef를 사용하면 setTimeout, setInterval, addEventListener 등에서 최신 상태를 유지하면서도 리렌더링 없이 동작할 수 있음. 예시: 타이머 핸들러가 state를 읽는데, 타이머 자체는 다시 생성되지 않도록 유지하고 싶을 때.

2. 외부 라이브러리와의 연동 (예: WebSocket, 이벤트 리스너 등)
외부 라이브러리에서 콜백 함수를 등록해야 하지만, 함수 변경으로 인해 불필요한 등록/해제가 발생하면 비효율적임. useRef를 사용하면 기존 이벤트 리스너를 유지하면서도 최신 함수 로직만 갱신할 수 있음.

3. 컴포넌트 내부에서 "함수를 상태처럼 관리"해야 하지만, 변경 시 리렌더링이 필요 없는 경우
특정한 로직을 상태로 관리하고 싶지만, UI 갱신이 필요하지 않다면 useRef를 활용할 수 있음.

 

그러면 useRef(initialValue)에서 initialValue에 함수를 담으면 어떻게 될까.

useState 처럼 동작할까.

 

초기 값에 함수를 담는 경우

아래처럼 useRef에 콘솔을 출력하는 함수를 담아보자.

import React, { useRef } from "react";

export default function RequestTest() {
  const callback = useRef(() => {
    console.log("함수실행");
  });

  return <button onClick={() => callback.current()}>click</button>;
}

 

프로젝트를 실행해보면, button을 클릭했을 때 콘솔에 "함수실행" 이라고 정상적으로 출력된다.

useState에 담았던 경우에는 초기 값의 함수가 실행되어 반환 값이 담기던 것과 대조적이다.

 

초기 값에 함수 호출을 담는 경우

아래에서는 초기 값에 함수 호출을 담았다.

이 때 리렌더링이 발생한다면 초기 값의 함수 호출은 다시 실행될까?

import React, { useRef } from "react";

export default function RequestTest() {
  const [count, setCount] = useState(0);

  const callback = useRef((() => {
      const randomValue = Math.random();
      console.log("함수 준비", randomValue);
      return () => console.log("함수 실행", randomValue);
    })());

  return 
  <>
     <button onClick={() => setCount((prev) => prev + 1)}> add count: {count} </button>
 	<button onClick={() => callback.current()}>click</button>
  </>
  ;
}

실행 결과, 최초 렌더링 때에는 "함수 준비 ${랜덤 값}"이 출력되었고, 버튼 클릭 시 "함수 실행 ${랜덤 값}"이라 출력되었다.(두 랜덤 값은 동일)

다음은 add count 버튼을 눌러서 count 상태를 변경 및 리렌더링을 일으켰다.

그 결과, 리렌더링마다 콘솔에 "함수 준비  ${랜덤 값} "이 매번 출력되었고 랜덤 값은 매번 달라졌다.

그리고 버튼 클릭 시  "함수 실행 ${랜덤 값}"이 출력되었지만 랜덤 값은 최초 렌더링 때 설정된 값으로 고정되어 있었다.

 

즉, 이 부분은 앞서 말한 useState의 초기 값에 함수 호출을 담는 경우와 동일하다.

앞의 내용
"매 렌더링마다 initialState의 라인을 다시 실행한다.
즉, 매 렌더링마다 실행은 하는데, 초기 값으로 렌더링되는 건 최초의 렌더링 때 뿐이다.
따라서 initialState가 함수의 호출이라면 매번 이것이 실행되는 것이다."

 

대안

반복적 호출을 막기 위해서는 어떻게 해야 할까.

앞서 useState의 경우에는 초기 값을 함수로 세팅하여 반복적인 호출을 방지했다.

하지만, useRef는 초기 값을 함수로 세팅해도 실행되지 않으니, 반복 실행을 막도록 useCallback이나 useMemo를 사용하면 된다.

import React, { useRef } from "react";

export default function RequestTest() {
  const [count, setCount] = useState(0);

  const cacheCallback = useMemo(() => {
    const randomValue = Math.random();
    console.log("함수 준비", randomValue);
    return () => console.log("함수 실행", randomValue);
  }, []);

  const callback = useRef(cacheCallback);

  return 
  <>
     <button onClick={() => setCount((prev) => prev + 1)}> add count: {count} </button>
 	<button onClick={() => callback.current()}>click</button>
  </>
  ;
}

 

참고자료

https://y-chyachya.tistory.com/88

 

https://ko.react.dev/reference/react/useState#avoiding-recreating-the-initial-state

 

https://maystar8956.tistory.com/206#article-1-1--reactfiberhooks-js-(dispatcher)