useEvent [RFC] 떠먹여드립니다.

dante Yoon·2022년 7월 28일
18

react

목록 보기
5/19
post-thumbnail

안녕하세요 단테입니다. 오늘은 리엑트의 RFC(Request for Comments
) 항목 중 하나인 useEvent에 대해 알아보겠습니다.

본 포스팅 내용 영상입니다.
https://www.youtube.com/watch?v=lPlg3zUPYMA

useEvent RFC github discussion

Disclaimer

useEvent 훅은 리엑트 18 알파 버전과 무관하다.
useEvent hooks has nothing to do with react 18 alpha version

왜 생겨난 건데?

event handler 내부에서 상태 값을 참조할 때

link

리엑트 훅의 이벤트 핸들러 내부에서 참조하는 컴포넌트의 상태 값은 항상 최신 값이라고 보장할 수 없는데, 이를 해결하기 위해 상태 값을 useRef로 래핑해 핸들러 내부에서 참조하게 해야 한다. 이에 대한 좋은 예시가 있어 가져왔다.

const UserList = () => {
    const [users, setUsers] = useState([])
    useEffect(() => {
        const socket = io('/dashboard')
        socket.on('user:connect', (user) => {
            setUsers([...users, user])
        })
        socket.on('user:update', (user) => {
            let newUsers = users.map((u) => u.id == user.id ? user : u)
            setUsers(newUsers)
        }) 
    }, [])

    return (
        users.map(({id, email}) => (
            <tr key={id}>
                <td>{id}</td>
                <td>{email}</td>
            </tr>
        ))
    )
}

코드 참조: https://github.com/facebook/react/issues/14092

useEffect 내부에서 선언된 소켓 이벤트 핸들러에서 참조하는 users 값은 초기 선언된 [] 에서 더 이상 새로운 값으로 업데이트 되지 못한다.

이를 해결하기 위해서는 ref를 사용하거나 setState 내부에서 함수를 사용하여 이전 상태 값을 명시적으로 참조해서 호출해야 한다.

setUsers(users => [...users, user])

users 값을 setUsers가 아닌 다른 데서 참조해야 한다면? ref 말고는 마땅한 방법이 떠오르지 않는다.

이러한 문제 제기에 대해 dan abramov는 훅이 주는 불편함을 무려 4년 전에 일찍이 인정했었다.


link

useEvent 필요성의 두 번째 이유는 상태 변화에 따른 컴포넌트 로직 수행을 위해 useEffect 내부에서 특정 상황을 나타내는 flag 변수를 useRef로 선언하여 사용해야 한다는 점에서 발생한다. 이를테면 isMountedRef와 같은 것이다.

useEffect(() => {
  if(!isMountedRef.current) {
    doSomething();
    isMountedRef.current = true;
  }
}, [someValue])

상태 변화에 따른 컴포넌트 로직 수행을 useEffect 훅에서 주로 수행하다보니 n개의 로직 수행을 위해 n개의 useEffect를 선언하거나 특정 useEffect가 비대해지는 문제가 발생한다. 이뿐만이 아니다. 각 상태 값을 참조하는 useRef까지 사용하게 되면 어느 순간 하나의 컴포넌트가 거대한 상태 머신이 되어 버린다.

useCallback invalidating too often

invalidating은 재할당, 재생산된다라고 번역할 수 있을 것 같다.

useCallback은 메모아이징을 통해 자식 컴포넌트에게 props로 전달되는 함수가 재생산되지 않게 만드는 용도로 사용한다. dependency array에 포함된 상태값이 업데이트 되면 재생산되기 때문에 상태값 변경에 따라 너무 자주 재생산되는 문제가 있다.

무슨 말인지 잘 모르겠지?

자,

  1. useCallback은 컴포넌트 리렌더링에 따른 함수 재할당(재생성)이 일어나는 것을 막기 위해 사용된다.

  2. useCallback의 callback 함수가 참조하는 변수 값은 항상 최신 상태를 유지함을 보장해야 자식 컴포넌트에서 정상적으로 사용할 수 있다.

  3. 콜백 함수에서는 참조하는 변수의 최신화를 보장하기 위해 useCallback dependency에 참조 props/state를 넣어야 한다.

  4. 결국 콜백 함수가 참조하는 props/state 값이 변경되면 자식 컴포넌트도 리렌더링 된다.

  5. 원래 의도했던 수준만큼의 옵티마이징이 되지 않는다.

결국 useCallback은 뭔가 양날의 검을 넘어선 잘쓰기 참 애매한 도구가 되어버렸다.

이러한 문제는 리엑트 공식 문서에도 기술되어 있다.

useCallback은 메모아이징을 하기 때문에 함수 내부에서 참조하는 상태 값은 dependency array에 함께 인자로 제공해주지 않는한 최신 값을 참조할 수 없다.

결국 event handler를 useCallback의 callback으로 사용하는 경우 내부 참조 상태 값이 최신화되지 않으면 이 핸들러는 자식 컴포넌트에서 사용할 수 없기 때문에 상태 값이 변경할 때마다 callback 함수도 재생산해줘야 한다.

암울하게도 이에 대한 해결점은 ref다.

function Form() {
  const [text, updateText] = useState('');
  const textRef = useRef();

  useEffect(() => {
    textRef.current = text; // Write it to the ref
  });

  const handleSubmit = useCallback(() => {
    const currentText = textRef.current; // Read it from the ref
    alert(currentText);
  }, [textRef]); // Don't recreate handleSubmit like [text] would do

  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}

좀 더 괜찮은 방법은 useReducer로 만든 dispatch 함수를 contextAPI로 내보내 자식 컴포넌트에서 해당 값을 가져와 사용하는 거다. 왜 그런지 궁금하다면 앞서 공유한 링크를 클릭해 공식 문서를 읽어보자.

useEvent

자, 이제 본게임으로 들어가자.

function Chat() {
  const [text, setText] = useState('');

  const onClick = useEvent(() => {
    sendMessage(text);
  });

  return <SendButton onClick={onClick} />;
}

useEvent는 dependency array가 없다. useEvent로 래핑된 핸들러가 참조하는 props/state는 항상 최신 값으로 유지된다.

props/state는 항상 최신 값으로 유지된다. 이 부분이 핵심이다.

앞서 살펴봤던 소켓 예제 코드에서 발견된 문제점을 useEvent를 통해 어떻게 해결하는지 살펴보자.

function Chat({ selectedRoom }) {
  const [muted, setMuted] = useState(false);
  const theme = useContext(ThemeContext);

  // ✅ Stable identity
  const onConnected = useEvent(connectedRoom => {
    showToast(theme, 'Connected to ' + connectedRoom);
  });

  // ✅ Stable identity
  const onMessage = useEvent(message => {
    showToast(theme, 'New message: ' + message);
    if (!muted) {
      playSound();
    }
  });

  useEffect(() => {
    const socket = createSocket('/chat/' + selectedRoom);
    socket.on('connected', async () => {
      await checkConnection(selectedRoom);
      onConnected(selectedRoom);
    });
    socket.on('message', onMessage);
    socket.connect();
    return () => socket.disconnect();
  }, [selectedRoom]); // ✅ Re-runs only when the room changes
}

useEffect의 dependency array에는 onMessage와 onConnected가 있지 않다. 왜냐하면 onConnected, onMessage는 useEvent에 의해 항상 stable identity, 즉 참조값이 변경되지 않음을 보장하기 때문이다. setState를 dependency array에 넣을 필요가 없는 것과 동일한 이치이다. setState는 stable identity함을 보장한다.

useEvent는 stable identity하기 때문에 eventHandler에서 참조하는 props/state가 변경되더라도 자식 컴포넌트에서 수행되는 referential equality 비교에서 리렌더링을 유발하지 않는다.

되게 중요한 포인트니까 위 단락은 다시 한번 읽어보자.

passing arguments to events

await checkConnection(selectedRoom);가 호출되는 동안 컴포넌트 props인 selectedRoom가 room A 에서 room B로변경되었다고 가정해보자.

useEvent는 props, state 변경에 따른 최신 값을 참조하기 때문에 onConnected가 호출되는 시점에 selectedRoom은 B를 가르킨다.

const onConnected = useEvent(connectedRoom => {
  console.log(selectedRoom); // already "Room B"
  showToast(theme, 'Connected to ' + connectedRoom); // "Room A" passed from effect
});

checkConnection이 호출되는 시점의 selectedRoom을 참조해야 하는 경우가 있기 때문에 useEvent 콜백 함수의 arguments를 통해 이벤트 핸들러 내부에서 과거/최신 props/state 값을 함께 사용할 수 있다.

extracting an event from an effect

document.addEventListner, socket.on handler만 useEvent 의 callback 함수가 될 수 있는 것은 아니다.
특정 상황에 useEffect 내부에서 호출되는 모든 함수들이 useEvent 래핑의 대상이 될 수 있다.

유저 url이 변경됨에 따라 page view를 기록하는 logAnalytics함수를 사용한다고 가정하자.
아래 함수는 currentUser.name이 변경됨에 따라 로그 함수(이벤트)를 다시 호출한다.
logAnalytics에서 currentUser.name을 정상적으로 참조하기 위해서는 dependency array에 currentUser.name이 꼭 들어가야 한다.

function Page({ route, currentUser }) {
  useEffect(() => {
    logAnalytics('visit_page', route.url, currentUser.name);
  }, [route.url, currentUser.name]);
  // ...
}

이러한 문제점는 useEvent를 이용해서 다음과 같이 쉽게 해결할 수 있다.

function Page({ route, currentUser }) {
  // ✅ Stable identity
  const onVisit = useEvent(visitedUrl => {
    logAnalytics('visit_page', visitedUrl, currentUser.name);
  });

  useEffect(() => {
    onVisit(route.url);
  }, [route.url]); // ✅ Re-runs only on route change
  // ...
}

앞선 코드와 비교했을 때 로직이 두 부분으로 분리되었다.

  • route.url이 변경됨에 따라 event를 fire하는 reactive한 부분 - useEffect
  • currentUser.name의 최신 값을 참조할 수 있는 non-reactive한 부분 -useEvent

useEvent로 래핑된 logAnalytics는 최신 currentUser.name 값이 변경됨에 따라
앱의 생태주기동안 url이 변경될 때만 onVisit 이벤트가 호출되고, currentUser.name도 이벤트 핸들러 내부에서 정상적으로 최신 값으로 참조된다.

무슨 마법이 벌어지는 걸까?

// (!) Approximate behavior

function useEvent(handler) {
  const handlerRef = useRef(null);

  // In a real implementation, this would run before layout effects
  useLayoutEffect(() => {
    handlerRef.current = handler;
  });

  return useCallback((...args) => {
    // In a real implementation, this would throw if called during render
    const fn = handlerRef.current;
    return fn(...args);
  }, []);
}

실제 userEvent 구현 로직은 위와 다른 부분이 있다.

useLayoutEffect는 리엑트가 돔을 조작한 이후에 호출되지만 실제 useEvent는 render phase에 호출되면 에러를 뱉는다. (무시된다.) rendering output에 영향을 미치지 못하기 때문에 이들은 reactive 하지 않다.

내부 구현에 대해서는 코드를 아직 코드를 보지 못했고 스펙이 변경될 수도 있어 추후 좀 더 깊게 분석해보는 포스팅이 있을 예정이다.

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글