React Effect에서 useEffectEvent는 언제 사용하나요?
Effect는 단순히 처음 한 번 실행하는 함수가 아니라 외부 시스템과 현재 상태를 맞추는 과정입니다. 구독은 유지하면서 이벤트 시점의 최신 옵션만 읽어야 할 때 useEffectEvent를 검토합니다.
적용 조건·범위: 값 변화가 외부 시스템 동기화 조건이라면 Effect dependency로 유지합니다.
본문 근거: React Lifecycle of Reactive Effects · React Separating Events from Effects · React useEffectEvent
독립 실습: 구독을 유지하면서 최신 옵션 읽기
상세 설정을 바꾸고 이벤트 버튼을 눌러 최신 설정에 따른 로그를 확인하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
useEffectEvent를 제공하는 React 19.2 이상을 사용합니다.
src/App.tsx 전체 코드
import { useEffect, useEffectEvent, useState } from 'react';
export default function App() {
const [debug, setDebug] = useState(false);
const onPing = useEffectEvent(() => console.log(debug ? '상세 로그' : '기본 로그'));
useEffect(() => {
console.log('구독');
const handlePing = () => onPing();
window.addEventListener('practice-ping', handlePing);
return () => window.removeEventListener('practice-ping', handlePing);
}, []);
return (
<main>
<label><input type="checkbox" checked={debug} onChange={e => setDebug(e.target.checked)} />상세</label>
<button onClick={() => window.dispatchEvent(new Event('practice-ping'))}>이벤트</button>
</main>
);
}
예상 로그와 직접 확인: 첫 클릭은 기본 로그, 상세 체크 후 클릭은 상세 로그입니다. 체크 변경만으로 구독 로그가 늘지 않아야 합니다. StrictMode의 최초 추가 setup은 별도로 셉니다.
변형 과제: 버튼은 practice-ping 이벤트를 발생시키고, Effect가 등록한 handlePing이 onPing을 호출합니다. onPing을 일반 onClick에 직접 연결하는 방식과 이 흐름이 다른 이유를 설명합니다.
Effect는 동기화 단위로 생각합니다
채팅 서버 연결을 예로 들면 roomId가 바뀔 때는 이전 방 연결을 끊고 새로운 방에 연결해야 합니다.
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
이 Effect는 roomId에 반응해야 합니다.
반면 “연결 성공 시 현재 테마로 알림을 보여준다”는 로직은 테마가 바뀌었다고 서버 연결까지 다시 만들 필요는 없습니다.
reactive logic과 non-reactive logic
Effect 내부에서 읽는 모든 값이 같은 의미를 갖는 것은 아닙니다.
- 값이 바뀌면 외부 시스템 연결 자체가 다시 동기화되어야 함 → dependency
- 최신 값은 필요하지만 외부 연결을 다시 만들 필요는 없음 → Effect Event 후보
useEffectEvent
import {
useEffect,
useEffectEvent,
} from 'react';
function Chat({
roomId,
theme,
}: {
roomId: string;
theme: string;
}) {
const onConnected = useEffectEvent(() => {
showNotification('연결됨', theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return null;
}
theme의 최신 값은 onConnected 안에서 읽지만 theme 때문에 서버 연결 Effect가 다시 실행되지는 않습니다.
useEffectEvent를 dependency 회피용으로 쓰면 안 됩니다
다음 사고방식은 피해야 합니다.
dependency 경고가 난다
→ useEffectEvent로 옮긴다
→ dependency에서 제거한다
실제로 값이 바뀌었을 때 Effect가 다시 동기화되어야 한다면 dependency로 남아야 합니다.
예를 들어 roomId를 Effect Event로 숨기면 방이 바뀌어도 연결이 바뀌지 않는 버그가 생길 수 있습니다.
이벤트와 Effect를 구분합니다
사용자가 버튼을 클릭해서 무언가를 저장하는 일은 보통 이벤트입니다.
function handleSave() {
saveTask();
}
컴포넌트가 화면에 존재하는 동안 외부 시스템과 상태를 맞추는 일은 Effect입니다.
useEffect(() => {
document.title = title;
}, [title]);
“사용자 이벤트 때문에 실행되는가”와 “화면에 존재하는 동안 동기화해야 하는가”를 구분하면 Effect 수를 줄일 수 있습니다.
자주 발생하는 오류
Effect Event를 일반 이벤트 핸들러처럼 사용합니다
Effect Event는 Effect 안에서 호출되는 non-reactive 로직을 분리하기 위한 용도입니다. 일반 onClick을 대체하는 API로 생각하지 않습니다.
Effect Event를 자식 컴포넌트에 전달합니다
Effect Event는 해당 Effect와 가까운 곳에서 사용합니다. 일반 콜백 API처럼 다른 컴포넌트로 전달하지 않습니다.
실제 reactive 값을 숨깁니다
값 변화가 외부 시스템 동기화 조건이라면 Effect dependency로 유지합니다.
학습 완료 체크리스트
- Effect를 외부 시스템 동기화 관점으로 설명할 수 있다
- 값이 바뀌면 Effect가 다시 실행되어야 하는 경우를 구분할 수 있다
- 최신 값을 읽기만 하면 되는 로직과 reactive 로직을 분리할 수 있다
- useEffectEvent를 dependency 제거용 편법으로 사용하지 않는다
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
예상 화면·상태: 네트워크 변경은 UI에 반영되고 debug 옵션 변경만으로 listener가 불필요하게 다시 연결되지 않는 것이 목표입니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 15단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React useEffect 두 번 실행되는 이유 StrictMode API 중복 해결의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/NetworkStatus.tsx, src/hooks/useNetworkStatus.ts. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
변경 순서: 네트워크 상태 구독의 핸들러에서 최신 showDebugMessage를 읽는 부분을 useEffectEvent로 옮깁니다. 아래 최종 파일 경로와 비교해 두 리스너의 cleanup을 유지합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.
독자 질문과 직접 확인
질문: 네트워크 상태 구독은 유지하면서 최신 debug 옵션만 이벤트 시점에 읽으려면 어떻게 하나요?
컴포넌트 실습: step-15-effect-event에서 온라인·오프라인 상태를 바꾸고 debug 옵션을 변경합니다. 상태 표시와 이벤트 시점의 debug 값, listener 연결 횟수를 각각 비교합니다.
예상 화면·상태: 네트워크 변경은 UI에 반영되고 debug 옵션 변경만으로 listener가 불필요하게 다시 연결되지 않는 것이 목표입니다. useEffectEvent를 모든 의존성 회피 수단으로 사용하지 않습니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.
오류 점검: 값이 오래되면 Effect 내부의 반응형 값과 Effect Event에서 읽는 값을 구분하고, 중복 연결이면 cleanup을 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 16회에서 Effect와 state 변경의 순환 오류를 찾습니다.
완료 코드 ZIP 다운로드 — step-15-effect-event
이전 체크포인트:
step-14-effect-strict-mode
TaskFlow는 미완료 작업 수를 document.title에 표시하고, 네트워크 상태를 Effect로 동기화하고 있습니다.
이번 단계에서는 online·offline 이벤트를 구독하는 동안 최신 showDebugMessage 옵션을 읽습니다. 옵션만 바뀔 때 리스너를 다시 연결하지 않고, 실제 네트워크 이벤트가 발생했을 때 새 옵션으로 로그 표시 여부를 판단합니다.
Before
useEffect(() => {
function handleOnline() {
console.log(
showDebugMessage
? 'TaskFlow가 온라인 상태입니다.'
: 'online',
);
setIsOnline(true);
}
window.addEventListener('online', handleOnline);
return () => {
window.removeEventListener('online', handleOnline);
};
}, [showDebugMessage]);
showDebugMessage가 바뀔 때마다 listener를 제거하고 다시 등록합니다.
After
const onOnline = useEffectEvent(() => {
console.log(
showDebugMessage
? 'TaskFlow가 온라인 상태입니다.'
: 'online',
);
setIsOnline(true);
});
useEffect(() => {
function handleOnline() {
onOnline();
}
window.addEventListener('online', handleOnline);
return () => {
window.removeEventListener('online', handleOnline);
};
}, []);
옵션 변경으로는 외부 구독을 다시 만들지 않고, 이벤트가 실제 발생했을 때 최신 showDebugMessage 값을 읽습니다. 최초 개발 모드 StrictMode의 추가 setup·cleanup은 옵션 변경에 의한 재연결과 구분합니다.
TaskFlow의 기존 state·form·목록 코드는 이전 체크포인트 상태를 유지합니다.
실행 확인
- 브라우저 네트워크 상태를 바꿨을 때 UI가 갱신되는지 확인합니다.
- debug 표시 옵션을 바꿔도 이벤트 listener가 불필요하게 다시 연결되지 않는지 확인합니다.
- 최신 debug 옵션 값이 실제 이벤트 시점에 반영되는지 확인합니다.
npm run build
완료 체크포인트:
step-15-effect-event
전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.
전체 파일·코드 보기
실습 프로젝트 전체 코드
index.html
<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
package.json
{
"name": "taskflow-step-15-effect-event",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.0",
"@vitejs/plugin-react": "6.1.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "26.6.2"
}
}
src/App.tsx
import NetworkStatus from './components/NetworkStatus';
import StateResetDemo from './components/StateResetDemo';
import {
useEffect,
useState,
} from 'react';
import Header from './components/Header';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import type { Task } from './types/task';
const initialTasks: Task[] = [
{
id: 'task-1',
title: 'StrictMode와 cleanup 확인하기',
completed: false,
},
];
function App() {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const remainingCount = tasks.filter(
(task) => !task.completed,
).length;
useEffect(() => {
document.title = `TaskFlow (${remainingCount})`;
}, [remainingCount]);
const handleAdd = (title: string) => {
setTasks((previousTasks) => [
...previousTasks,
{
id: crypto.randomUUID(),
title,
completed: false,
},
]);
};
const handleToggle = (id: string) => {
setTasks((previousTasks) =>
previousTasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
);
};
const handleDelete = (id: string) => {
setTasks((previousTasks) =>
previousTasks.filter((task) => task.id !== id),
);
};
return (
<main className="app">
<Header />
<NetworkStatus />
<p>남은 작업: {remainingCount}</p>
<TaskForm onAdd={handleAdd} />
<TaskList
tasks={tasks}
onToggle={handleToggle}
onDelete={handleDelete}
/>
<StateResetDemo />
</main>
);
}
export default App;
src/components/Header.tsx
export default function Header() {
return (
<header>
<p className="eyebrow">React Learning Project</p>
<h1>TaskFlow</h1>
<p>오늘 할 일을 하나씩 정리해 보세요.</p>
</header>
);
}
src/components/NetworkStatus.tsx
import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
const [showDebugMessage, setShowDebugMessage] = useState(false);
const isOnline = useNetworkStatus(showDebugMessage);
return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
<label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}
src/components/StateResetDemo.tsx
import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
{ id: 'example-1', title: '첫 번째 작업', completed: false },
{ id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
const [selectedId, setSelectedId] = useState(examples[0].id);
const selectedTask = examples.find(task => task.id === selectedId)!;
return <details><summary>state 초기화 실습</summary>
<p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
{examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
<TaskEditor key={selectedTask.id} task={selectedTask} />
</details>;
}
src/components/TaskCard.tsx
import type { ReactNode } from 'react';
type TaskCardProps = {
children: ReactNode;
actions?: ReactNode;
};
export default function TaskCard({
children,
actions,
}: TaskCardProps) {
return (
<li className="task-card">
<div className="task-card__content">
{children}
</div>
{actions ? (
<div className="task-card__actions">
{actions}
</div>
) : null}
</li>
);
}
src/components/TaskEditor.tsx
import { useState } from 'react';
import type { Task } from '../types/task';
type TaskEditorProps = {
task: Task;
};
export default function TaskEditor({
task,
}: TaskEditorProps) {
const [title, setTitle] = useState(task.title);
return (
<section>
<h2>작업 편집</h2>
<label htmlFor="editor-title">편집 입력 연습</label>
<input id="editor-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<p>선택한 작업 ID: {task.id}</p>
</section>
);
}
src/components/TaskForm.tsx
import {
useRef,
useState,
type FormEvent,
} from 'react';
type TaskFormProps = {
onAdd: (title: string) => void;
};
export default function TaskForm({
onAdd,
}: TaskFormProps) {
const [title, setTitle] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (
event: FormEvent<HTMLFormElement>,
) => {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) {
inputRef.current?.focus();
return;
}
onAdd(trimmedTitle);
setTitle('');
inputRef.current?.focus();
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="task-title">
새 작업
</label>
<input
ref={inputRef}
id="task-title"
value={title}
onChange={(event) =>
setTitle(event.target.value)
}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
src/components/TaskItem.tsx
import type { Task } from '../types/task';
import TaskCard from './TaskCard';
type TaskItemProps = {
task: Task;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
};
export default function TaskItem({
task,
onToggle,
onDelete,
}: TaskItemProps) {
return (
<TaskCard
actions={
<>
<button
type="button"
onClick={() => onToggle(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<button
type="button"
onClick={() => onDelete(task.id)}
>
삭제
</button>
</>
}
>
<span>{task.title}</span>
</TaskCard>
);
}
src/components/TaskList.tsx
import TaskItem from './TaskItem';
import type { Task } from '../types/task';
type TaskListProps = {
tasks: Task[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
};
export default function TaskList({
tasks,
onToggle,
onDelete,
}: TaskListProps) {
if (tasks.length === 0) {
return <p>등록된 작업이 없습니다.</p>;
}
return (
<ul>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
src/hooks/useNetworkStatus.ts
import {
useEffect,
useEffectEvent,
useState,
} from 'react';
export function useNetworkStatus(
showDebugMessage: boolean,
) {
const [isOnline, setIsOnline] = useState(
navigator.onLine,
);
const onOnline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 온라인 상태입니다.');
}
setIsOnline(true);
});
const onOffline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 오프라인 상태입니다.');
}
setIsOnline(false);
});
useEffect(() => {
const handleOnline = () => onOnline();
const handleOffline = () => onOffline();
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener(
'online',
handleOnline,
);
window.removeEventListener(
'offline',
handleOffline,
);
};
}, []);
return isOnline;
}
src/index.css
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
color: #111;
background: #f5f5f5;
}
.app {
width: min(720px, calc(100% - 32px));
margin: 48px auto;
}
.eyebrow {
color: #666;
font-size: 14px;
}
ul {
padding-left: 20px;
}
button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }
src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
src/types/task.ts
export type Task = {
id: string;
title: string;
completed: boolean;
};
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"types": [
"vite/client"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"vite.config.ts"
]
}
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });
글 소개·학습 준비·전체 목차

React · 15/32 · TaskFlow 누적 실습
React Effect 생명주기와 useEffectEvent 사용 기준
React · 15 / 32 · 필수 학습 · Effect 생명주기·useEffectEvent
useEffect를 “컴포넌트가 mount될 때 한 번 실행하는 함수”라고만 이해하면 dependency를 다루기 어려워집니다. Effect는 외부 시스템과 현재 React 상태를 동기화하는 과정입니다.
이 글에서는 컴포넌트 생명주기와 Effect의 동기화 주기를 구분하고, Effect 안에서 최신 값을 읽되 그 값 때문에 Effect를 다시 연결할 필요는 없을 때 useEffectEvent를 사용하는 기준을 설명합니다.
이번 글에서 배우는 내용
- 컴포넌트 생명주기와 Effect 동기화 주기의 차이
- reactive value
- Effect가 다시 동기화되는 조건
- Effect Event의 역할
- useEffectEvent 사용법
- dependency를 숨기는 용도로 쓰면 안 되는 이유
목차
다음 학습
다음 글에서는 Effect와 state 업데이트가 서로를 반복 호출할 때 발생하는 Maximum update depth 오류를 직접 재현하고 진단합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.