useCallback의 한계점과 useEvent
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
는 이러한 동작을 기반으로 합니다.)
ref
를 생성합니다. 그리고 이 ref는 같은 메모리 공간에서 항상 고유한 ID를 가집니다.- 이를 위해
useEffect
를 실행하고,ref.current
에callback
을 넣습니다. - 그리고 이 과정을 렌더링마다 실행시켜,
ref.current
의callback
을 최신화합니다.
→ 이 과정에서 아무런 리렌더링도 일어나지 않습니다. ref
는 컴포넌트의 리렌더링을 트리거 하지 않으니까요.
- 그리고, 이
ref
을useMemo
로 메모이제이션 합니다.
→ 디펜던시 어레이가 비어져 있어도 괜찮습니다.
결과적으로 이 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
를 막을 수 있습니다.