useEffectEvent ?
React 공식 문서의 Separating Events from Effects 섹션에서는 useEffectEvent에 대해 설명하고 있습니다. "event와 effect를 분리한다"는 개념이 다소 낯설게 느껴질 수 있는데, 간단한 예시를 통해 자세히 알아보겠습니다.
useTimeout Hook 구현
먼저, useTimeout이라는 hook을 구현해 보겠습니다. 이 hook은 지정된 시간(밀리초) 후에 콜백 함수를 실행해줍니다.
아래는 기본적인 useTimeout 구현 코드입니다.
const useTimeout = (callback: () => void, delay: number) => { const timer = useRef<NodeJS.Timeout | null>(null); const start = useCallback(() => { timer.current = setTimeout(() => { callback(); }, delay); }, [callback, delay]); useEffect(() => { return () => { if (timer.current) clearTimeout(timer.current); }; }, []); return useMemo(() => ({ start }), [start]); };
이제 이 hook을 사용해 보겠습니다.
export const App = () => { const { start } = useTimeout(() => { console.log('time out !!'); }, 3000); return ( <main> <button onClick={start}>시작</button> </main> ); };
시작 버튼을 누르고 3초를 기다리면
time out !! 이 출력됩니다. 이 경우, 코드는 정상적으로 동작합니다.
라고 생각하면 안됩니다.
어떤 문제가 있을까요? 사용 예제를 다음과 같이 수정해 보겠습니다.
export const App = () => { const [count, setCount] = useState(0); const { start } = useTimeout(() => { console.log(`time out !! 현재 카운트: ${count}`); }, 3000); return ( <main> <button onClick={start}>시작</button> <button onClick={() => setCount(count + 1)}>증가 (현재: {count})</button> </main> ); };
이제 시작 버튼을 클릭한 후, count 증가 버튼을 여러 번 누르면
총 10번 정도 눌렀음에도 불구하고
콘솔에는 여전히 0이 출력됩니다.
현재 최신 상태가 반영이 안돼요.
사실 이건 당연한 문제입니다.이 문제는 당연한 결과입니다.
start 함수가 실행되면 내부의 setTimeout도 호출되는데, 이때 콜백 함수가 클로저로 캡처되어 이후의 상태 변경이 반영되지 않기 때문입니다.
useEffectEvent를 사용하면 해결 가능
아직은 stable 버전으로 나온게 아니기 때문에
react@experimental react-dom@experimental eslint-plugin-react-hooks@experimental
실험 버전으로 설치를 해줘야합니다. (type은 지원을 안하는거 같습니다.)
import { experimental_useEffectEvent as useEffectEvent } from 'react';
이렇게 import를 한 후
const useTimeout = (_callback: () => void, delay: number) => { const timer = useRef<NodeJS.Timeout | null>(null); const callback = useEffectEvent(_callback); const start = useCallback(() => { timer.current = setTimeout(() => { callback(); }, delay); }, [callback, delay]); useEffect(() => { return () => { if (timer.current) clearTimeout(timer.current); }; }, []); return useMemo(() => ({ start }), [start]); };
이렇게 파라미터로 받은 callback에 넣어줍니다.
이제 다시 실행시켜보면
새로운 상태가 제대로 반영되는 것을 확인할 수 있습니다.
폴리필
해당 기능은 아직 실험 단계이므로, 당장 React에서 제공하는 hook을 사용하는 것은 무리가 있습니다. 이 경우 use-effect-event 라이브러리를 설치하여 사용할 수 있습니다.
use-effect-event 내부 구현
import {useCallback, useInsertionEffect, useRef} from 'react' export function useEffectEvent< const T extends ( ...args: // eslint-disable-next-line @typescript-eslint/no-explicit-any any[] ) => void, >(fn: T): T { const ref = useRef<T | null>(null) useInsertionEffect(() => { ref.current = fn }, [fn]) // eslint-disable-next-line @typescript-eslint/no-explicit-any return useCallback((...args: any) => { const latestFn = ref.current! return latestFn(...args) }, []) as unknown as T }
내부 구현은 정말 간단합니다.
동작원리를 살펴보펴보면, 우선 이 hook에서는 항상 같은 참조값을 유지하는 함수를 리턴합니다. useCallback의 의존성 배열에 아무것도 없기 때문이죠. 그리고 리렌더링이 발생하면 다음과 같은 과정이 진행됩니다.
- rerendering이 발생한다.
- fn의 참조가 바뀐다.
- useInsertionEffect가 실행되어, 새로운 상태값이 반영된 fn이 ref에 저장된다.
즉, 함수의 참조는 그대로 유지되지만 내부 내용은 최신 상태로 계속 업데이트됩니다.
언제 useEffectEvent를 사용하면 좋을까 ?
앞서 살펴본 예시처럼, setTimeout에서 callback 함수를 실행할 때 유용하며, websocket, setInterval 등 특정 함수가 실행될 때 callback 함수가 클로저에 의해 최신 상태를 반영하지 못하는 상황에서도 효과적입니다.