녕후킴

usePrevious 훅의 약점 보완하기

0 views

usePrevious 훅을 검색해보면 보통 아래와 같이 작성된다.

// usePrevious.ts export default function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; }

그리고 아래와 같이 import하여 사용할 수 있다.

// App.tsx function App() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); useEffect(() => { console.log(count, prevCount); }, [count, prevCount]); return ( <div> <h1> Now: {count} <br /> Before: {prevCount} </h1> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }

코드 출처

App 컴포넌트 내의 useEffect 내부에서 count와 prevCount에 대해서 콘솔을 찍어보면 prevCount는 count의 항상 이전 값을 보여준다. 이와 관련하여 이해가 안가는 부분이 존재했다.

내가 생각한 것은 다음과 같다. useEffect 내부의 로직은 렌더링이 발생한 후에 실행된다. 그러므로 ref.current가 바뀌는 것도 렌더링이 된 후이다. 더불어서 App 컴포넌트의 useEffect 내 prevCount는 이미 값이 바뀌어 있으므로 콘솔을 찍었을 때 count와 prevCount가 같은 값을 가져야 한다. 라는게 내 생각이었다. 하지만 그렇지 않다.

답은 다음 할당문에 존재했다.

const prevCount = usePrevious(count);

useEffect 내에서 count와 prevCount가 동일한 값을 갖지 않는 이유는, 이전 값을 접근할 때 ref.current를 통해서 접근하는게 아니라 prevCount를 통해서 접근하기 때문이다. ref.current값이 바뀌기 전에는 count의 이전 값을 가지고 있다. 이 값을 우선 prevCount에 할당하고, 렌더링이 끝나면 ref.current를 count의 최신값에 업데이트함으로써 useEffect 내에서 서로 다른 값을 가질 수 있는 것이다.

usePrevious 강화하기

앞선 usePrevious 훅은 한 가지 문제점을 안고있다. 다음과 같이 App 코드가 작성돼 있다고 가정해보자

function App() { const [count, setCount] = useState(0); const [_, forceRerender] = useState({}); const prevCount = usePrevious(count); useEffect(() => { console.log(count, prevCount); }, [count, prevCount]); return ( <div> <button onClick={() => forceRerender({})}>force rerender</button> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }

Increment 버튼을 세번 누르면 useEffect 내의 count와 prevCount는 어떤 값이 찍힐까? 각각 3과 2가 찍힐 것이다. 이 상태에서 만약 force rerender 버튼을 누르면 useEffect 내에서는 어떤 값이 찍힐까? 3과 3이 찍힌다. count 상태가 변하지 않았음에도 불구하고, prevCount가 count값과 동일해지는 것이다.

이러한 문제를 해결하기 위해서 usePrevious를 다음과 같이 수정할 수 있다.

export const usePreviousPersistent = <TValue extends unknown>( value: TValue ) => { // P1 const ref = useRef<{ value: TValue; prev: TValue | null }>({ value: value, prev: null }); // P2 const current = ref.current.value; // P3 if (value !== current) { ref.current = { value: value, prev: current }; } // P4 return ref.current.prev; };

주석을 따라서 설명하면 다음과 같다.

  1. P1 - 기존의 usePrevious 훅과는 다르게 ref 내에 이전 값을 저장하는게 아니라, 이전 값과 현재 값을 프로퍼티로 갖는 객체를 저장한다. usePrevious는 항상 prev 프로퍼티 값을 반환한다.
  2. P2 - ref가 저장하는 객체의 현재 값인 value를 curent에 할당한다.
  3. P3 - 인자로 전달되는 value와 current(ref가 기존에 기억하고 있던 value)가 다르다면, ref가 관찰하는 상태가 업데이트 된 것이므로, ref가 기존에 기억하고 있던 value는 이전 값이 되므로 prev 프로퍼티에 할당하고, 새로 기억해야 하는 value는 value 프로퍼티에 할당한다.

만약 객체를 비교해야 하는 경우 deep equality를 사용해야 하지만 글쓴이는 라이브러리에 따라서 속도가 느릴 수 있기 때문에 별로 선호하지 않는다고 한다. 그래서 matcher 함수를 전달하는 다음 방식을 제안하고 있다.

export const usePreviousPersistentWithMatcher = <TValue extends unknown>( value: TValue, isEqualFunc: (prev: TValue, next: TValue) => boolean ) => { const ref = useRef<{ value: TValue; prev: TValue | null }>({ value: value, prev: null }); const current = ref.current.value; if (isEqualFunc ? !isEqualFunc(current, value) : value !== current) { ref.current = { value: value, prev: current }; } return ref.current.prev; };

참고 문헌

Implementing advanced usePrevious hook with React useRef