React 18에서 고쳐진 버그 (feat. Dan의 답변)

Lee Jeong Min·2023년 3월 22일
26

개발

목록 보기
19/20
post-thumbnail

문제

아래와 같은 react 18 버전 코드가 있다.

import { MouseEvent, useEffect, useState } from "react";
import type { ReactNode } from "react";

type DropdownProps = {
  target: HTMLElement | null;
  children: ReactNode;
};

function Dropdown({ target, children }: DropdownProps) {
  useEffect(() => {
    if (!target) return;

    const listener = () => {
      console.log("listener executed");
    };

    document.addEventListener("click", listener);
    return () => document.removeEventListener("click", listener);
  }, [target]);

  if (!target) {
    return null;
  }

  return <div>{children}</div>;
}

function App() {
  const [target, setTarget] = useState<HTMLButtonElement | null>(null);

  return (
    <div>
      <button
        onClick={(e: MouseEvent<HTMLButtonElement>) =>
          setTarget(e.currentTarget)
        }
      >
        Button
      </button>
      <Dropdown target={target}>dropdown test</Dropdown>
    </div>
  );
}

export default App;

코드 샌드박스 링크: https://codesandbox.io/s/sad-firefly-sphup6?file=/src/App.tsx

이 코드에 대해서 간략하게 설명하면, targetfalsy 값일 경우에는 Dropdown 컴포넌트의 listener가 실행되고 있지 않다가 targetfalsy 값이 아니게 되면, 이벤트 리스너가 부착되는 코드이다.

이 코드의 동작방식을 예측하면, 당연히 처음 버튼을 눌렀을 때, target의 값이 초기화되면서, document에 listener가 부착되고, 그 이후에 버튼을 다시 눌렀을 때 listener가 실행되는 것을 예상했다. 그러나 실제 동작방식은 아래의 화면과 같았다.

버튼을 누르자마자 listener가 실행된것이다.

react 17에선 같은 코드가 이렇게 동작하지 않고, react 18의 createRoot를 사용할 때, 이런 일이 발생하였는데 왜 이런일이 발생한 것일까?

Dan abramov의 답변

계속 고민하다가 왜 이렇게 동작하는 지 이해가 안가서 다음과 같이 facebook/react 레포에 Issue를 올렸다.

우선 결론부터 말하면 저 동작은 의도된 동작이라는 것이다.

처음에 이 답변을 듣고, 내가 React를 지금까지 잘못 알고 있었나 생각이 들었다.

위 이슈에 달린 Dan의 답변을 토대로 정리해보면 원래 React17에서는 이러한 동작이 일관성 없게 동작을 하였다고 한다. 즉, 저렇게 작성을 하여도 어떤 경우에는 이벤트가 부착만 되고 이벤트 리스너가 실행되지 않는 경우가 있었고, 이벤트가 동기식으로 처리되어 이벤트 리스너가 실행되는 경우도 있었다고 한다.

아래 예시 코드샌드박스를 보면 확인할 수 있다.

즉, 정리해보면 처음 문제의 코드에서 원래 이벤트가 부착이 되고 listener가 실행되는 동작이 정상적인 동작이며 17에선 버그였다고 한다. 그림으로 그려보면 아래와 같다.

18에서는 이러한 동작이 일관되어 항상 이벤트를 포착하고, 클릭/입력과 같은 의도적인 사용자 이벤트로 인한 렌더링 효과는 항상 동기적으로 처리 된다고 한다.

처음 의도한 대로 코드를 실행시키려면?

우선 Dan이 댓글에 언급한 대로, setTimeout 방법이 있다.

setTimeout

이벤트의 부착하는 시점을 setTimeout으로 감싼다.

    setTimeout(() => {
      document.addEventListener("click", listener);
    }, 0);

추후 더 고민해보니 아래와 같은 방법으로도 해결할 수 있었다.

useDeferredValue

기존 코드의 target을 사용하는 부분을 useDeferredValue로 감싼 값으로 사용한다.

const deferredTarget = useDeferredValue(target);

startTransition

setState하는 부분의 코드를 startTransition으로 감싼다.

      <button
        onClick={(e: MouseEvent<HTMLButtonElement>) => {
          startTransition(() => {
            setTarget(e.currentTarget);
          });
        }}
      >

참고자료

profile
It is possible for ordinary people to choose to be extraordinary.

1개의 댓글

comment-user-thumbnail
2023년 3월 27일

잘 봤습니다. 감사합니다.

답글 달기