useEffectEvent

김동현·2026년 3월 17일

useEffectEvent

소개

useEffectEvent는 이벤트를 Effect로부터 분리할 수 있게 해주는 React Hook이에요.

const onEvent = useEffectEvent(callback)

목차


레퍼런스

useEffectEvent(callback)

컴포넌트의 최상위 레벨에서 useEffectEvent를 호출해서 Effect Event를 생성하세요.

import { useEffectEvent, useEffect } from 'react';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });
}

Effect Event는 Effect 로직의 일부이지만, 이벤트 핸들러처럼 동작해요. 항상 렌더링의 최신 값(props나 state 같은)을 "볼" 수 있지만 Effect를 다시 동기화하지 않기 때문에, Effect 의존성에서 제외돼요. Events를 Effects로부터 분리하기에서 더 알아보세요.

아래에서 더 많은 예제를 확인해보세요.

매개변수

  • callback: Effect Event의 로직을 담은 함수예요. 이 함수는 원하는 만큼의 인자를 받고 어떤 값이든 반환할 수 있어요. 반환된 Effect Event 함수를 호출할 때, callback은 항상 호출 시점에 렌더링에서 커밋된 최신 값에 접근해요.

반환값

useEffectEventcallback과 동일한 타입 시그니처를 가진 Effect Event 함수를 반환해요.

이 함수는 useEffect, useLayoutEffect, useInsertionEffect 내부에서, 또는 같은 컴포넌트의 다른 Effect Event에서 호출할 수 있어요.

주의사항

  • useEffectEvent는 Hook이기 때문에, 컴포넌트의 최상위 레벨이나 자체 Hook에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 필요하다면, 새 컴포넌트를 추출해서 Effect Event를 그쪽으로 옮기세요.
  • Effect Event는 Effect나 다른 Effect Event 내부에서만 호출할 수 있어요. 렌더링 중에 호출하거나 다른 컴포넌트나 Hook에 전달하지 마세요. eslint-plugin-react-hooks 린터가 이 제한을 강제해요.
  • Effect의 의존성 배열에 의존성을 지정하는 것을 피하기 위해 useEffectEvent를 사용하지 마세요. 이것은 버그를 숨기고 코드를 이해하기 어렵게 만들어요. Effect에서 발생하는 이벤트 로직에만 사용하세요.
  • Effect Event 함수는 안정적인 정체성을 가지지 않아요. 정체성이 매 렌더링마다 의도적으로 변경돼요.
Deep Dive: Effect Event가 왜 안정적이지 않나요?

useStateset 함수나 ref와 달리, Effect Event 함수는 안정적인 정체성을 가지지 않아요. 정체성이 매 렌더링마다 의도적으로 변경돼요:

// 🔴 잘못됨: Effect Event를 의존성에 포함
useEffect(() => {
  onSomething();
}, [onSomething]); // ESLint가 경고할 거예요

이것은 의도적인 설계 선택이에요. Effect Event는 같은 컴포넌트의 Effect 내부에서만 호출되도록 설계됐어요. 로컬에서만 호출할 수 있고 다른 컴포넌트에 전달하거나 의존성 배열에 포함할 수 없기 때문에, 안정적인 정체성은 쓸모가 없고 오히려 버그를 숨길 수 있어요.

안정적이지 않은 정체성은 런타임 단언 역할을 해요: 코드가 함수 정체성에 잘못 의존하면, 매 렌더링마다 Effect가 다시 실행되는 것을 보게 되어 버그가 명확해져요.

이 설계는 Effect Event가 개념적으로 특정 effect에 속하고, 반응성을 회피하기 위한 범용 API가 아니라는 것을 강화해요.


사용법

Effect에서 이벤트 사용하기

컴포넌트의 최상위 레벨에서 useEffectEvent를 호출해서 Effect Event를 생성하세요:

const onConnected = useEffectEvent(() => {
  if (!muted) {
    showNotification('Connected!');
  }
});

useEffectEvent이벤트 콜백을 받아서 Effect Event를 반환해요. Effect Event는 Effect를 다시 연결하지 않고도 Effect 내부에서 호출할 수 있는 함수예요:

useEffect(() => {
  const connection = createConnection(roomId);
  connection.on('connected', onConnected);
  connection.connect();
  return () => {
    connection.disconnect();
  }
}, [roomId]);

onConnectedEffect Event이기 때문에, mutedonConnect가 Effect 의존성에 없어요.

주의

의존성을 건너뛰기 위해 Effect Event를 사용하지 마세요

"불필요하다고" 생각하는 의존성을 나열하지 않기 위해 useEffectEvent를 사용하고 싶을 수 있어요. 하지만 이것은 버그를 숨기고 코드를 이해하기 어렵게 만들어요:

// 🔴 잘못됨: Effect Event로 의존성 숨기기
const logVisit = useEffectEvent(() => {
  log(pageUrl);
});

useEffect(() => {
  logVisit()
}, []); // pageUrl 누락은 로그를 놓친다는 의미예요

값이 Effect를 다시 실행하게 해야 한다면, 의존성으로 유지하세요. Effect를 다시 트리거하지 않아야 하는 로직에만 Effect Event를 사용하세요.

Events를 Effects로부터 분리하기에서 더 알아보세요.


최신 값으로 타이머 사용하기

Effect에서 setInterval이나 setTimeout을 사용할 때, 값이 변경될 때마다 타이머를 재시작하지 않고 렌더링의 최신 값을 읽고 싶을 때가 많아요.

이 카운터는 현재 increment 값만큼 count를 매초 증가시켜요. onTick Effect Event는 인터벌을 재시작하지 않고 최신 countincrement를 읽어요:

import { useState, useEffect, useEffectEvent } from 'react';

export default function Timer() {
  const [count, setCount] = useState(0);
  const [increment, setIncrement] = useState(1);

  const onTick = useEffectEvent(() => {
    setCount(count + increment);
  });

  useEffect(() => {
    const id = setInterval(() => {
      onTick();
    }, 1000);
    return () => {
      clearInterval(id);
    };
  }, []);

  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>
    </>
  );
}
button { margin: 10px; }

타이머가 실행 중일 때 increment 값을 변경해보세요. 카운터는 즉시 새로운 increment 값을 사용하지만, 타이머는 재시작 없이 부드럽게 계속 작동해요.


최신 값으로 이벤트 리스너 사용하기

Effect에서 이벤트 리스너를 설정할 때, 콜백에서 렌더링의 최신 값을 읽어야 할 때가 많아요. useEffectEvent 없이는, 의존성에 값을 포함해야 해서 변경될 때마다 리스너가 제거되고 다시 추가돼요.

이 예제는 커서를 따라가는 점을 보여주는데, "Can move"가 체크되어 있을 때만이에요. onMove Effect Event는 Effect를 다시 실행하지 않고 항상 최신 canMove 값을 읽어요:

import { useState, useEffect, useEffectEvent } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  const onMove = useEffectEvent(e => {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  });

  useEffect(() => {
    window.addEventListener('pointermove', onMove);
    return () => window.removeEventListener('pointermove', onMove);
  }, []);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}
body {
  height: 200px;
}

체크박스를 토글하고 커서를 움직여보세요. 점은 체크박스 상태에 즉시 반응하지만, 이벤트 리스너는 컴포넌트가 마운트될 때 한 번만 설정돼요.


외부 시스템에 다시 연결하지 않고 사용하기

useEffectEvent의 일반적인 사용 사례는 Effect에 대한 응답으로 무언가를 하고 싶지만, 그 "무언가"가 반응하고 싶지 않은 값에 의존할 때예요.

이 예제에서, 채팅 컴포넌트는 방에 연결하고 연결되면 알림을 표시해요. 사용자는 체크박스로 알림을 음소거할 수 있어요. 하지만 사용자가 설정을 변경할 때마다 채팅방에 다시 연결하고 싶지 않아요:

// package.json (숨김)
{
  "dependencies": {
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "toastify-js": "1.12.0"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}
import { useState, useEffect, useEffectEvent } from 'react';
import { createConnection } from './chat.js';
import { showNotification } from './notifications.js';

function ChatRoom({ roomId, muted }) {
  const onConnected = useEffectEvent((roomId) => {
    console.log('✅ Connected to ' + roomId + ' (muted: ' + muted + ')');
    if (!muted) {
      showNotification('Connected to ' + roomId);
    }
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    console.log('⏳ Connecting to ' + roomId + '...');
    connection.on('connected', () => {
      onConnected(roomId);
    });
    connection.connect();
    return () => {
      console.log('❌ Disconnected from ' + roomId);
      connection.disconnect();
    }
  }, [roomId]);

  return <h1>Welcome to the {roomId} room!</h1>;
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [muted, setMuted] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={muted}
          onChange={e => setMuted(e.target.checked)}
        />
        Mute notifications
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        muted={muted}
      />
    </>
  );
}
// src/chat.js
const serverUrl = 'https://localhost:1234';

export function createConnection(roomId) {
  // A real implementation would actually connect to the server
  let connectedCallback;
  let timeout;
  return {
    connect() {
      timeout = setTimeout(() => {
        if (connectedCallback) {
          connectedCallback();
        }
      }, 100);
    },
    on(event, callback) {
      if (connectedCallback) {
        throw Error('Cannot add the handler twice.');
      }
      if (event !== 'connected') {
        throw Error('Only "connected" event is supported.');
      }
      connectedCallback = callback;
    },
    disconnect() {
      clearTimeout(timeout);
    }
  };
}
// src/notifications.js
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';

export function showNotification(message, theme) {
  Toastify({
    text: message,
    duration: 2000,
    gravity: 'top',
    position: 'right',
    style: {
      background: theme === 'dark' ? 'black' : 'white',
      color: theme === 'dark' ? 'white' : 'black',
    },
  }).showToast();
}
label { display: block; margin-top: 10px; }

방을 전환해보세요. 채팅이 다시 연결되고 알림을 표시해요. 이제 알림을 음소거해보세요. muted가 Effect가 아닌 Effect Event 내부에서 읽히기 때문에, 채팅은 연결된 상태를 유지해요.


커스텀 Hook에서 Effect Event 사용하기

자체 커스텀 Hook 내부에서 useEffectEvent를 사용할 수 있어요. 이렇게 하면 일부 값을 비반응형으로 유지하면서 Effect를 캡슐화하는 재사용 가능한 Hook을 만들 수 있어요:

import { useState, useEffect, useEffectEvent } from 'react';

function useInterval(callback, delay) {
  const onTick = useEffectEvent(callback);

  useEffect(() => {
    if (delay === null) {
      return;
    }
    const id = setInterval(() => {
      onTick();
    }, delay);
    return () => clearInterval(id);
  }, [delay]);
}

function Counter({ incrementBy }) {
  const [count, setCount] = useState(0);

  useInterval(() => {
    setCount(c => c + incrementBy);
  }, 1000);

  return (
    <div>
      <h2>Count: {count}</h2>
      <p>Incrementing by {incrementBy} every second</p>
    </div>
  );
}

export default function App() {
  const [incrementBy, setIncrementBy] = useState(1);

  return (
    <>
      <label>
        Increment by:{' '}
        <select
          value={incrementBy}
          onChange={(e) => setIncrementBy(Number(e.target.value))}
        >
          <option value={1}>1</option>
          <option value={5}>5</option>
          <option value={10}>10</option>
        </select>
      </label>
      <hr />
      <Counter incrementBy={incrementBy} />
    </>
  );
}
label { display: block; margin-bottom: 8px; }

이 예제에서, useInterval은 인터벌을 설정하는 커스텀 Hook이에요. 전달된 callback이 Effect Event로 감싸지기 때문에, 매 렌더링마다 새로운 callback이 전달되더라도 인터벌이 재설정되지 않아요.


문제 해결

"useEffectEvent로 감싸진 함수는 렌더링 중에 호출할 수 없습니다" 오류가 발생해요

이 오류는 컴포넌트의 렌더링 단계 중에 Effect Event 함수를 호출하고 있다는 뜻이에요. Effect Event는 Effect나 다른 Effect Event 내부에서만 호출할 수 있어요.

function MyComponent({ data }) {
  const onLog = useEffectEvent(() => {
    console.log(data);
  });

  // 🔴 잘못됨: 렌더링 중에 호출
  onLog();

  // ✅ 올바름: Effect에서 호출
  useEffect(() => {
    onLog();
  }, []);

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

렌더링 중에 로직을 실행해야 한다면, useEffectEvent로 감싸지 마세요. 로직을 직접 호출하거나 Effect로 옮기세요.


"useEffectEvent에서 반환된 함수는 의존성 배열에 포함되면 안 됩니다" 린트 오류가 발생해요

"useEffectEvent에서 반환된 함수는 의존성 배열에 포함되면 안 됩니다" 같은 경고가 보인다면, 의존성에서 Effect Event를 제거하세요:

const onSomething = useEffectEvent(() => {
  // ...
});

// 🔴 잘못됨: 의존성에 Effect Event 포함
useEffect(() => {
  onSomething();
}, [onSomething]);

// ✅ 올바름: 의존성에 Effect Event 없음
useEffect(() => {
  onSomething();
}, []);

Effect Event는 의존성으로 나열하지 않고 Effect에서 호출되도록 설계됐어요. 함수 정체성이 의도적으로 안정적이지 않기 때문에 린터가 이를 강제해요. 포함하면 매 렌더링마다 Effect가 다시 실행될 거예요.


"... 은 useEffectEvent로 생성된 함수이고, Effect에서만 호출할 수 있습니다" 린트 오류가 발생해요

"... 은 React Hook useEffectEvent로 생성된 함수이고, Effect와 Effect Event에서만 호출할 수 있습니다" 같은 경고가 보인다면, 함수를 잘못된 곳에서 호출하고 있는 거예요:

const onSomething = useEffectEvent(() => {
  console.log(value);
});

// 🔴 잘못됨: 이벤트 핸들러에서 호출
function handleClick() {
  onSomething();
}

// 🔴 잘못됨: 자식 컴포넌트에 전달
return <Child onSomething={onSomething} />;

// ✅ 올바름: Effect에서 호출
useEffect(() => {
  onSomething();
}, []);

Effect Event는 정의된 컴포넌트의 Effect에서 로컬로 사용되도록 특별히 설계됐어요. 이벤트 핸들러용 콜백이 필요하거나 자식에 전달해야 한다면, 일반 함수나 useCallback을 대신 사용하세요.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글