React useEffect로 state와 브라우저 탭 제목을 어떻게 동기화하나요?
useEffect는 현재 props·state를 브라우저 API나 이벤트 구독처럼 React 바깥의 시스템에 맞출 때 사용합니다. 클릭 횟수는 이벤트에서 state로 바꾸고, 브라우저 탭 제목은 Effect에서 동기화합니다.
본문 근거: Synchronizing with Effects · You Might Not Need an Effect
독립 실습: 클릭 횟수와 브라우저 제목 동기화하기
카운터를 바꾸고 실제 iframe 문서 제목의 변화를 확인하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useEffect, useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const previousTitle = document.title;
document.title = `클릭 ${count}`;
return () => { document.title = previousTitle; };
}, [count]);
return <button onClick={() => setCount(value => value + 1)}>클릭 {count}</button>;
}
예상 결과와 직접 확인: 두 번 클릭하면 버튼과 브라우저 탭 제목이 모두 클릭 2가 되어야 합니다. JSX 계산과 document.title 변경 위치를 비교합니다.
변형 과제: 의존성 배열을 []로 바꾸면 생기는 차이를 확인한 뒤 [count]로 복구합니다.
렌더링과 Effect를 구분합니다
렌더링 코드는 props와 state를 바탕으로 JSX를 계산합니다.
const remainingCount = tasks.filter(
(task) => !task.completed,
).length;
이 값은 기존 state로 계산할 수 있으므로 Effect가 필요하지 않습니다.
반면 브라우저 document.title은 React state가 아닙니다. React 바깥의 브라우저 API와 상태를 맞춰야 하므로 Effect를 사용할 수 있습니다.
useEffect(() => {
document.title = 'TaskFlow';
}, []);
dependency 배열
Effect 안에서 사용하는 반응형 값이 바뀔 때 다시 동기화해야 한다면 dependency에 포함합니다.
useEffect(() => {
document.title = `TaskFlow (${remainingCount})`;
}, [remainingCount]);
remainingCount가 바뀔 때마다 브라우저 탭 제목도 함께 바뀝니다.
cleanup
외부 시스템에 연결하거나 이벤트를 등록했다면 해제 과정도 함께 생각해야 합니다.
useEffect(() => {
const handleResize = () => {
console.log(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
setup과 cleanup을 한 쌍으로 생각하면 다음 StrictMode 글도 이해하기 쉬워집니다.
Effect가 필요 없는 경우
다음은 Effect보다 렌더링 중 계산이 더 자연스럽습니다.
const visibleTasks = tasks.filter((task) =>
task.title.includes(keyword),
);
다음처럼 파생 데이터를 state로 다시 저장할 필요가 없습니다.
useEffect(() => {
setVisibleTasks(
tasks.filter((task) => task.title.includes(keyword)),
);
}, [tasks, keyword]);
원본 state에서 계산 가능한 값은 가능한 한 렌더링 중 계산합니다.
자주 발생하는 오류와 해결
dependency를 비워 두면 최신 값이 반영되지 않습니다
Effect 안에서 사용하는 값이 무엇인지 먼저 확인합니다.
모든 계산을 Effect로 옮겼습니다
외부 시스템과 동기화가 필요한지 질문합니다. 단순한 배열 filter, 문자열 조합, 개수 계산이라면 Effect가 아닐 가능성이 큽니다.
이벤트 핸들러에서 할 일을 Effect로 옮겼습니다
사용자의 클릭이나 submit 때문에 직접 발생하는 로직은 이벤트 핸들러가 더 명확합니다.
학습 완료 체크리스트
- 렌더링·이벤트·Effect를 구분할 수 있습니다.
- 외부 시스템 동기화가 필요한 사례를 설명할 수 있습니다.
- dependency 배열의 역할을 설명할 수 있습니다.
- cleanup이 필요한 Effect를 작성할 수 있습니다.
- 불필요한 Effect를 줄일 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
미완료 작업 수를 브라우저 탭 제목과 동기화합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 13단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React useRef 실습 입력 포커스와 state 역할 나누기의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
변경 순서: src/App.tsx에서 미완료 개수를 계산하고 [remainingCount] Effect로 document.title을 동기화합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.
독자 질문과 직접 확인
질문: 미완료 작업 수를 브라우저 탭 제목에 맞추려면 Effect의 의존성은 무엇이어야 하나요?
컴포넌트 실습: step-13-effect에서 작업을 하나 추가하고 다른 작업을 완료 처리합니다. 목록의 미완료 개수와 document.title에 표시된 개수를 각각 적어 비교합니다.
예상 화면·상태: 작업 목록이 변할 때 탭 제목의 숫자도 미완료 개수와 같아지는 것이 목표입니다. 탭 제목은 React 외부의 브라우저 문서 상태이므로 동기화 대상입니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.
오류 점검: 목록 숫자만 바뀌면 remainingCount 계산이 현재 tasks를 사용하는지, 제목 변경 Effect의 의존성 배열이 그 remainingCount를 포함하는지 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 14회에서 개발 모드 StrictMode의 Effect 재실행을 점검합니다.
완료 코드 ZIP 다운로드 — step-13-effect
이전 체크포인트
step-12-ref
이번 목표
미완료 작업 수를 브라우저 탭 제목과 동기화합니다.
Before
TaskFlow 안에서는 작업 수가 바뀌지만 브라우저 document.title은 고정되어 있습니다.
After
미완료 작업이 2개라면 다음처럼 표시됩니다.
TaskFlow (2)
TaskForm, TaskList, TaskItem, Task 타입은 이전 체크포인트와 동일합니다.
실행 확인
작업을 추가하거나 완료 상태를 바꿀 때 브라우저 탭 제목의 숫자도 바뀌는지 확인합니다.
완료 체크포인트:
step-13-effect
전체 코드 뷰어의 모든 파일은 다운로드 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-13-effect",
"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 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: 'useEffect 목적 이해하기',
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 />
<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/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/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 · 13/32 · TaskFlow 누적 실습
React useEffect 사용법 외부 시스템과 상태 동기화하기
React · 13 / 32 · 필수 학습 · useEffect
useEffect는 현재 props·state를 브라우저 API나 이벤트 구독처럼 React 바깥의 시스템과 맞출 때 사용합니다. 클릭 횟수는 state로 바꾸고 브라우저 탭 제목은 Effect에서 동기화하는 독립 예제로 렌더링·이벤트·Effect의 역할을 구분합니다.
React 공식 문서는 Effect를 React 바깥의 시스템과 컴포넌트를 동기화하는 방법으로 설명합니다. 브라우저 API, 네트워크 연결, 외부 위젯, 이벤트 구독처럼 렌더링만으로 처리할 수 없는 작업이 대표적입니다.
이번 글에서 배우는 내용
- Effect가 필요한 이유
- 렌더링·이벤트·Effect의 차이
- dependency 배열
- cleanup
- Effect가 필요 없는 대표 사례
- 브라우저 document.title 동기화
목차
다음 학습
다음 글에서는 개발 환경에서 Effect가 setup → cleanup → setup 순서로 다시 실행되는 이유를 StrictMode와 함께 확인합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.