React state는 언제 유지되며 key로 어떻게 초기화하나요?
React state는 변수 이름이 아니라 화면 트리의 위치와 컴포넌트에 연결됩니다. 같은 위치의 같은 컴포넌트는 state를 유지하며 key가 바뀌면 새 상태로 시작할 수 있습니다.
독립 실습: 대상을 바꿀 때 편집 입력 초기화하기
선택을 바꿔 key에 따른 입력값 초기화를 확인하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useState } from 'react';
function Editor({ name }: { name: string }) {
const [draft, setDraft] = useState(name);
return <input aria-label="편집" value={draft} onChange={e => setDraft(e.target.value)} />;
}
export default function App() {
const [person, setPerson] = useState('해비');
return (
<main>
<button onClick={() => setPerson('해비')}>해비 선택</button>
<button onClick={() => setPerson('도영')}>도영 선택</button>
<Editor key={person} name={person} />
</main>
);
}
직접 확인: 입력을 임시로 수정한 후 도영 선택을 누르면 도영으로 초기화됩니다. 같은 사람을 다시 선택하면 현재 입력이 유지됩니다.
변형 과제: Editor의 key를 제거하고 새로고침한 후 같은 순서를 반복해 차이를 확인합니다.
같은 위치의 state는 유지됩니다
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
);
}
부모가 다시 렌더링되어도 Counter가 같은 위치에 남아 있다면 count는 유지됩니다.
조건이 바뀌어도 같은 위치라면 유지될 수 있습니다
{isPrimary ? (
<Counter />
) : (
<Counter />
)}
두 분기가 코드상 따로 보여도 렌더 트리에서 같은 위치에 같은 Counter가 온다면 React는 같은 컴포넌트로 판단할 수 있습니다.
key로 다른 컴포넌트라고 알려줄 수 있습니다
<ProfileForm key={user.id} user={user} />
user.id가 바뀌면 React는 이전 ProfileForm을 재사용하지 않고 새 인스턴스를 만듭니다. 따라서 내부 state도 초기값부터 다시 시작합니다.
이 패턴은 편집 폼처럼 “대상이 바뀌면 로컬 입력 상태도 새로 시작해야 하는 화면”에서 유용합니다.
key를 무조건 reset 용도로 쓰면 안 됩니다
state를 유지해야 하는 탭이나 입력 화면에 매 렌더링마다 다른 key를 넣으면 state가 계속 사라집니다.
<Form key={Math.random()} />
이 코드는 렌더링할 때마다 Form을 새로 만들기 때문에 입력 중인 값이 유지되지 않습니다.
자주 발생하는 오류
다른 데이터를 전달했는데 input 값이 그대로입니다
초기 props로 useState를 만들고 이후 props만 바뀌었다면 로컬 state는 자동으로 초기화되지 않습니다.
const [title, setTitle] = useState(task.title);
task가 바뀌어도 title이 자동으로 다시 task.title이 되지는 않습니다.
대상이 바뀌면 완전히 새 폼으로 시작해야 한다면 key를 고려합니다.
조건문 안에서 컴포넌트를 새로 정의합니다
컴포넌트 함수를 부모 렌더링 내부에서 매번 새로 정의하면 React가 컴포넌트 타입을 안정적으로 유지하기 어렵습니다. 컴포넌트 정의는 일반적으로 바깥에 둡니다.
학습 완료 체크리스트
- state가 JSX 태그가 아니라 렌더 트리 위치와 연결된다는 점을 설명할 수 있다
- 같은 위치에서 state가 보존되는 이유를 설명할 수 있다
- key로 state를 초기화할 수 있다
- 리스트 식별 key와 reset key가 같은 identity 개념이라는 점을 설명할 수 있다
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
예상 화면·상태: 선택 id가 바뀌면 해당 편집 컴포넌트가 새 작업의 제목으로 초기화되는 것이 목표입니다. 같은 작업을 다시 선택한 경우 불필요한 초기화가 발생하지 않아야 합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 10단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React 리스트 key 경고 해결 기준 index key를 피해야 하는 이유의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/StateResetDemo.tsx, src/components/TaskEditor.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
변경 순서: src/components/StateResetDemo.tsx에서 선택 id를 관리하고 내부 TaskEditor의 key에 선택 id를 전달합니다. App.tsx에 데모를 연결합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.
독자 질문과 직접 확인
질문: 작업을 바꿔 선택했는데 편집 input에 이전 제목이 남으면 어디서 초기화하나요?
컴포넌트 실습: step-10-state-reset에서 첫 작업을 선택해 편집 input을 수정하고 두 번째 작업을 선택합니다. key={selectedTask.id}가 적용된 편집 컴포넌트와 key가 없는 경우의 입력값을 비교합니다.
예상 화면·상태: 선택 id가 바뀌면 해당 편집 컴포넌트가 새 작업의 제목으로 초기화되는 것이 목표입니다. 같은 작업을 다시 선택한 경우 불필요한 초기화가 발생하지 않아야 합니다. 이 내용은 독자가 확인할 기준이며 이번 편집에서 앱을 실행하거나 브라우저 화면을 관찰한 결과가 아닙니다.
오류 점검: 의도치 않게 입력 중 내용이 지워지면 렌더마다 바뀌는 랜덤 key나 불안정한 컴포넌트 위치를 살펴봅니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 11회에서는 새 작업 입력을 제어 컴포넌트로 구현합니다.
완료 코드 ZIP 다운로드 — step-10-state-reset
이전 체크포인트:
step-09-list-key
TaskFlow에 간단한 작업 편집 패널을 추가한다고 가정합니다.
선택한 작업이 바뀌면 편집 input도 해당 작업 제목으로 새로 시작해야 합니다.
이 입력은 state 보존과 초기화를 관찰하는 독립 실습입니다. 작업 제목을 저장하는 편집 기능은 아닙니다.
Before
<TaskEditor task={selectedTask} />
TaskEditor 내부:
const [title, setTitle] = useState(task.title);
다른 작업을 선택해도 같은 TaskEditor 위치가 유지되기 때문에 이전 title state가 남을 수 있습니다.
After
<TaskEditor
key={selectedTask.id}
task={selectedTask}
/>
task.id가 바뀌면 TaskEditor를 새 인스턴스로 만들어 title state도 새 작업 제목으로 초기화합니다.
App 적용 예시
const selectedTask =
tasks.find((task) => task.id === selectedTaskId) ?? null;
{selectedTask ? (
<TaskEditor
key={selectedTask.id}
task={selectedTask}
/>
) : null}
이 단계의 목적은 완성된 편집 기능을 만드는 것이 아니라 컴포넌트 identity와 state reset의 관계를 확인하는 것입니다.
실행 확인
- 첫 번째 작업을 선택합니다.
- 편집 input의 제목을 임의로 바꿉니다.
- 두 번째 작업을 선택합니다.
- input이 두 번째 작업 제목으로 초기화되는지 확인합니다.
npm run build
완료 체크포인트:
step-10-state-reset
전체 코드 뷰어의 모든 파일은 다운로드 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-10-state-reset",
"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 { useState } from 'react';
import Header from './components/Header';
import TaskList from './components/TaskList';
import type { Task } from './types/task';
const initialTasks: Task[] = [
{
id: 'task-1',
title: '안정적인 key 사용하기',
completed: false,
},
];
function App() {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const handleAddSample = () => {
const newTask: Task = {
id: crypto.randomUUID(),
title: '새 작업',
completed: false,
};
setTasks((previousTasks) => [
...previousTasks,
newTask,
]);
};
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 />
<button type="button" onClick={handleAddSample}>
샘플 작업 추가
</button>
<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/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) {
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 · 10/32 · TaskFlow 누적 실습
React state 보존 초기화 key로 컴포넌트 상태 리셋하기
React · 10 / 32 · 필수 학습 · state 보존·초기화
React의 state는 변수 이름이 아니라 렌더 트리의 위치와 연결됩니다. 같은 위치에 같은 컴포넌트가 렌더링되면 React는 이전 state를 유지합니다.
이 원리를 모르고 있으면 “다른 작업을 선택했는데 입력값이 그대로 남아 있다”거나 “컴포넌트를 다시 보여줬는데 왜 초기화되지 않지?” 같은 문제가 생깁니다.
이 글에서는 state가 언제 보존되고 언제 초기화되는지 설명하고, key를 이용해 의도적으로 컴포넌트 state를 초기화하는 방법을 다룹니다.
이번 글에서 배우는 내용
- state가 렌더 트리 위치에 연결되는 방식
- 같은 위치·같은 컴포넌트 타입의 state 보존
- 컴포넌트 타입이 바뀌면 state가 초기화되는 이유
- key로 state를 의도적으로 초기화하는 방법
- 리스트 key와 state reset용 key의 공통 원리
- 폼·편집 화면에서 reset을 설계하는 기준
목차
다음 학습
다음 글에서는 실제 입력 폼을 제어 컴포넌트로 만들고, 입력 state와 tasks state의 소유 위치를 구분합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.