React useState로 객체·배열의 추가, 완료, 삭제를 어떻게 갱신하나요?
useState는 렌더링 사이에 값을 기억하고 setter로 다음 화면을 요청합니다. 객체·배열은 직접 수정하지 말고 새 값으로 교체합니다. 목록을 추가·완료·삭제하는 예제로 항목 수와 화면 표시가 바뀌는지 비교합니다.
독립 실습: 배열 항목 추가·완료·삭제
작업을 추가하고 완료 상태를 바꾼 뒤 삭제해 보세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useState } from 'react';
type Task = { id: string; title: string; completed: boolean };
export default function App() {
const [tasks, setTasks] = useState<Task[]>([]);
function add() {
const newTask: Task = { id: crypto.randomUUID(), title: '샘플 작업', completed: false };
setTasks(previous => [...previous, newTask]);
}
function toggle(id: string) {
setTasks(previous => previous.map(task =>
task.id === id
? { ...task, completed: !task.completed }
: task
));
}
function remove(id: string) {
setTasks(previous => previous.filter(task => task.id !== id));
}
return (
<main>
<button onClick={add}>추가</button>
<p>전체 {tasks.length}</p>
<ul>
{tasks.map(task => (
<li key={task.id}>
{task.title} · {task.completed ? '완료' : '진행 중'}
<button onClick={() => toggle(task.id)}>완료 전환</button>
<button onClick={() => remove(task.id)}>삭제</button>
</li>
))}
</ul>
</main>
);
}
직접 확인: 추가 두 번 → 전체 2, 첫 항목 완료 전환 → 첫 항목만 완료, 첫 항목 삭제 → 전체 1입니다.
변형 과제: 완료한 항목만 지우는 버튼을 filter로 작성하고 진행 중 항목이 유지되는지 확인합니다.
state란
이 절의 짧은 코드는 개념을 분해한 부분 코드입니다. 아래 독립 실습에는 import·타입·컴포넌트·이벤트 연결을 모두 포함한 App.tsx 전체 코드가 있습니다.
배열 수정 절의 setTasks는 useState의 setter, newTask는 추가할 작업 객체, id는 클릭한 작업의 식별자입니다. 아래 전체 예제의 add·toggle·remove 함수 안에 각각 선언되어 있습니다. 함수 밖에서 조각만 실행하지 않습니다.
일반 변수는 컴포넌트가 다시 실행될 때 UI 상태로 유지되지 않습니다.
React에서 화면에 영향을 주고 렌더링 사이에 기억해야 하는 값은 state로 관리합니다.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
useState는 현재 값과 값을 변경하는 setter를 반환합니다.
이전 state를 기준으로 업데이트합니다
현재 값에 1을 더하는 것처럼 이전 state가 필요한 계산은 함수형 업데이트가 안전합니다.
setCount((previous) => previous + 1);
여러 업데이트가 같은 이벤트에서 연속될 때 특히 중요합니다.
배열 state에 항목 추가
기존 배열을 직접 변경하지 않고 새 배열을 만듭니다.
setTasks((previousTasks) => [
...previousTasks,
newTask,
]);
배열 항목 수정
map으로 변경할 항목만 새 객체로 만듭니다.
setTasks((previousTasks) =>
previousTasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
);
배열 항목 삭제
filter로 삭제 대상만 제외합니다.
setTasks((previousTasks) =>
previousTasks.filter((task) => task.id !== id),
);
자주 발생하는 오류와 해결
push를 사용했는데 화면이 갱신되지 않습니다
잘못된 예:
tasks.push(newTask);
setTasks(tasks);
같은 배열 객체를 다시 전달하고 있습니다.
새 배열을 만듭니다.
setTasks((previousTasks) => [
...previousTasks,
newTask,
]);
setState 직후 console.log가 이전 값입니다
setter는 현재 실행 중인 코드의 변수를 즉시 바꾸는 명령이 아닙니다. 다음 렌더링에 사용할 state 업데이트를 요청합니다.
다음 값을 계산해야 한다면 현재 변수에 의존하기보다 함수형 updater를 사용합니다.
학습 완료 체크리스트
- 일반 변수와 state의 차이를 설명할 수 있습니다.
- useState를 선언할 수 있습니다.
- 이전 state 기반 업데이트를 작성할 수 있습니다.
- 배열 항목을 추가·수정·삭제할 수 있습니다.
- state를 직접 변경하면 안 되는 이유를 설명할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
tasks를 useState로 바꿔 완료와 삭제 버튼이 실제 UI를 바꾸게 만듭니다. 아직 제어 폼은 배우지 않았으므로 작업 추가는 고정된 샘플 제목을 추가하는 버튼으로 구현합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 7단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React children 사용법 컴포넌트 합성과 재사용 UI 설계의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
변경 위치와 순서: src/App.tsx의 상수 tasks를 useState로 바꾼 후 추가·토글·삭제 핸들러를 선언합니다. TaskList.tsx와 TaskItem.tsx의 기존 함수 props에 연결합니다. 아래 전체 코드의 파일별 최종 형태와 비교하고 버튼마다 목록 개수와 완료 표시를 확인합니다.
독자 질문과 직접 확인
질문: 샘플 작업을 추가·완료·삭제할 때 배열 state는 어떻게 바뀌어야 하나요?
컴포넌트 실습: step-07-state에서 샘플 작업 추가를 한 번 누르고, 새 항목의 완료 버튼을 누른 뒤 삭제합니다. 각 클릭 직후 작업 수와 완료 표시를 기록하고 같은 버튼을 다시 눌러 봅니다.
예상 화면·상태: 추가 후 목록 수가 1 늘고, 완료 클릭은 해당 항목의 표시만 바꾸며, 삭제 후 목록 수가 1 줄어듭니다. 이전 배열을 직접 변경하지 않는 업데이트가 기준입니다. 이 내용은 독자가 확인할 기준이며 이번 편집에서 앱을 실행하거나 브라우저 화면을 관찰한 결과가 아닙니다.
오류 점검: 화면이 그대로면 setTasks에 새 배열을 반환했는지, 이벤트 핸들러가 클릭 시 실행되는지 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 08회에서 객체·배열을 직접 수정했을 때의 갱신 오류를 재현합니다.
완료 코드 ZIP 다운로드 — step-07-state
이전 체크포인트
step-06-composition
현재 상태
tasks 배열은 상수이고 버튼 클릭은 Console에 id만 출력합니다.
이번 목표
tasks를 useState로 바꿔 완료와 삭제 버튼이 실제 UI를 바꾸게 만듭니다. 아직 제어 폼은 배우지 않았으므로 작업 추가는 고정된 샘플 제목을 추가하는 버튼으로 구현합니다.
Before
const tasks = [...]
버튼 클릭 → console.log
After
useState<Task[]>
버튼 클릭 → state 변경 → 화면 재렌더링
실행 확인
- 샘플 작업 추가를 누르면 새 항목이 늘어납니다.
- 완료 버튼을 누르면 완료 상태가 바뀝니다.
- 삭제를 누르면 목록에서 항목이 사라집니다.
npm run build
build까지 성공하면 완료입니다.
step-07-state
전체 코드 뷰어의 모든 파일은 다운로드 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-07-state",
"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 { 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: 'useState 기본 구조 익히기',
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}
/>
</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/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/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 (
<section>
<h2>작업 목록</h2>
<ul>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
</section>
);
}
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 · 7/32 · TaskFlow 누적 실습
React useState 사용법 state와 객체 배열 업데이트 기준
React · 7 / 32 · 필수 학습 · useState
사용자가 버튼을 눌렀을 때 화면의 값과 목록을 바꾸려면 렌더링 사이에 값을 기억할 상태가 필요합니다. useState는 컴포넌트가 렌더링 사이에 값을 기억하고, 값이 바뀌면 다시 렌더링하게 만드는 React Hook입니다.
상단에서는 useState 자체를 독립적으로 설명합니다. 하단에서는 TaskFlow 작업 배열을 state로 바꾸고 작업 추가·완료·삭제를 실제 동작으로 연결합니다.
이번 글에서 배우는 내용
- state와 일반 변수의 차이
- useState 기본 문법
- setter가 렌더링을 예약하는 방식
- 이전 state를 기준으로 업데이트하는 방법
- 객체와 배열 state를 불변 방식으로 변경하는 이유
- 작업 추가·완료·삭제 패턴
목차
다음 학습
다음 글에서는 일부러 배열과 객체를 직접 변경해 “state를 바꿨는데 화면이 예상대로 갱신되지 않는 문제”를 재현하고 올바른 업데이트 방식으로 수정합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.