OTTER-LOG

useCallback의 한계점과 useEvent

useCallback의 한계점과 useEvent
by otter2023년 2월 14일에 최종수정되었습니다.
잘못된 내용이 있으면 댓글을 달아주세요.

useCallback의 한계점

이전 글에서 useCallback 을 고민하고, 공부해 보았습니다. useCallback 이 가지고 있는 한계점이 있습니다. 우리가 위에서 useCallback 으로 메모한 함수는 디펜던시 어레이가 없었습니다. 그런데, useCallback 이 어떠한 특정 값에 따라 달라지는 함수라면 어떻게 될까요?

const Chat = () => { const [text, setText] = useState(""); const consoleText = useCallback(() => { console.log(Text); }, [Text]); // 이제 useCallback에 디펜던시 어레이가 생겼습니다. // 이 함수는 text가 달라질때마다 다시 생성됩니다. return ( <React.Fragment> <input value={text} onChange={(e) => setText(e.target.value)} /> <Button onButtonClick={consoleText}>버튼</Button> </React.Fragment> ); }; const Button = React.memo(({ onButtonClick }) => { return <button onClick={onButtonClick}>버튼</button>; });

input 에 타이핑만을 하고, 이를 프로파일러를 통해 확인해본 결과 Button 컴포넌트는 계속해서 리렌더링이되고 있습니다. 사실, 이는 당연한 결과입니다.

const [text, setText] = useState(""); const onButtonClick = useCallback(() => { console.log(text); }, [text]); // useCallback의 디펜던시 어레이의 값이 바뀌게 되면, 함수를 재생성합니다.

useCallback 의 디펜던시 어레이에 있는 값을 기반으로 useCallback 은 함수를 재생성할지, 말아야할지 판단하기 때문입니다. 값이 변하지 않았다면 함수를 재생성하지 않았겠지만 우리는 input 의 입력을 통해 text 를 언제나 만들어내고 있습니다. text 가 바뀌었으므로 함수는 상태가 바뀔때마다 재생성되고 이는 결과적으로 memo 를 사용하더라도 props 가 달라졌다고 판단됩니다.

그러면, 극단적으로 useCallback 의 디펜던시를 빼버립시다.

그러면 Button 컴포넌트는 메모됩니다. 그런데, Button 컴포넌트의 onButtonClick 에 문제가 생깁니다. text 의 최신값을 반영하지 못합니다.

useCallback 을 다시 생각해봅시다. useCallback 의 디펜던시 어레이를 비어둠으로써 우리는 함수 재생성을 막았으나, 이는 text 라는 최신의 상태를 반영하지 못합니다. 또 이를 위해 useCallback 의 디펜던시에 text 를 넣는다면 text 가 바뀔때마다 함수를 재생성해 Button 컴포넌트는 리렌더링됩니다.

결과적으로 우리는 함수의 메모리 주소를 고정시켜 Button 컴포넌트의 리렌더링은 막고싶지만, text 의 최신의 상태값은 반영하고 싶습니다. 그러면 이를 어떻게 해결할 수 있을까요?

useEvent

( 최근시점 확인해보니, 이 훅은 나오지 않는걸로 결정되었습니다! 다만 비슷한 역할을 하는 훅이 나올 것 같아요.)

대부분의 오픈소스들에는 useCallbackRef 등의 이름으로 사용되고 있습니다. 일단 결과를 확인해 봅시다. 이 훅을 쓰면 우리는 위의 문제를 해결할 수 있을까요? 일단 결과를 먼저 확인해 보겠습니다.

const Chat = () => { const [text, setText] = useState(""); const consoleText = useEvent(() => { console.log(Text); }); return ( <React.Fragment> <input value={text} onChange={(e) => setText(e.target.value)} /> <Button onButtonClick={consoleText}>버튼</Button> </React.Fragment> ); }; const Button = React.memo(({ onButtonClick }) => { return <button onClick={onButtonClick}>버튼</button>; });

역시 위와 똑같은, 방법을 통해 type 을 입력할때는 프로파일러로 확인해 보고 추후에 버튼을 클릭해 최신값을 반영하고 있는지 확인해 보았습니다. 둘 모두가 해결됐습니다. 그런데 어떻게 이렇게 가능했을까요?

아래는 radix-ui 에서 사용하고 있는 useCallbackRef 훅입니다.

function uesCallbackRef(callback) { const callbackRef = useRef(callback); React.useEffect(() => { callbackRef.current = callback; }); return React.useMemo( () => (...args) => callbackRef.current?.(...args), [], ); }

(용어의 통일성을 위해, 아래부터 useEvent 로 통일합니다. useEvent 는 이러한 동작을 기반으로 합니다.)

  1. ref 를 생성합니다. 그리고 이 ref는 같은 메모리 공간에서 항상 고유한 ID를 가집니다.
  2. 이를 위해 useEffect 를 실행하고, ref.currentcallback 을 넣습니다.
  3. 그리고 이 과정을 렌더링마다 실행시켜, ref.currentcallback 을 최신화합니다.

→ 이 과정에서 아무런 리렌더링도 일어나지 않습니다. ref 는 컴포넌트의 리렌더링을 트리거 하지 않으니까요.

  1. 그리고, 이 refuseMemo 로 메모이제이션 합니다.

→ 디펜던시 어레이가 비어져 있어도 괜찮습니다.

결과적으로 이 ref 는 언제나 callback 의 최신값을 반영하지만, 렌더링에 영향을 끼치지 않습니다.

그래서 우리는 위와 같은 useCallback 의 문제를 해결할 수 있습니다.


useEvent 어떻게 사용할까?

위와 같은 useEvent 를 또 어떻게 사용할 수 있을까요? 상태의 최신값을 반영할 수 있지만, 렌더링에 영향을 끼치지 않는 다는 점은 굉장히 유용해 보입니다.

Avoid useEffect

export default function Timer() { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1); useEffect(() => { const id = setInterval(() => { setCount(c => c + increment); }, 1000); return () => { clearInterval(id); }; }, [increment]); return ( <> <h1> Counter: {count} <button onClick={() => setCount(0)}>Reset</button> </h1> <hr /> <p> Every second, increment by: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}></button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }

(ref : https://beta.reactjs.org/learn/separating-events-from-effects )

위의 타이머를 실행시켜보면, 버튼을 클릭했을때 잠시 중단되는 현상을 보입니다. 이는, useEffect 의 디펜던시 어레이에 increment 가 존재하기 때문입니다. 그렇기 때문에, increment 가 달라졌을때 useEffect 가 실행됩니다. useEvent 를 이용해 이벤트를 분리하면 다음과 같이 작성할 수 있습니다.

export default function Timer() { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1); const onTick = useEvent(() => { setCount(c => c + increment); }); // useEvent를 통해 increment의 최신값 계속해서 반영합니다. useEffect(() => { const id = setInterval(() => { onTick(); }, 1000); return () => { clearInterval(id); }; }, []); // 이 useEffect는 렌더링되면 실행되지만, 디펜던시 어레이에 따라 리렌더링되지 않습니다. return ( <> <h1> Counter: {count} <button onClick={() => setCount(0)}>Reset</button> </h1> <hr /> <p> Every second, increment by: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}></button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }

이러한 방식을 통해서, 우리는 useEffect 를 조금 더 잘 사용할 수 있습니다. 위는 간단한 예시지만 만약 다음은 위와 같은 상황을 간략하게 작성한 코드입니다.

useEffect(() => { // v1, v2, v3 중 하나가 바뀌더라도 리렌더링됩니다. // 우리는 v1만을 의도했지만, v2 또는 v3 이 바뀌어도 effect 됩니다. , [v1, v2, v3]} // 이런 상황을 useEvent를 사용해, const callbackFn = useEvent(() => somthing about v3 ) useEffect(() => { callbackFn() , [v1, v2] } // 디펜던시 어레이를 줄여 필요하지 않은 리렌더링을 줄일 수 있습니다. // 하지만 useEvent를 통해 v3의 최신값은 항상 반영합니다. // 그리고 v1, v2 만 변경될때 effect 할 수 있습니다.

이를 통해 우리는 필요하지 않다고 생각하는 effect 를 막을 수 있습니다.