React state 업데이트 안됨 문제 해결

··설명 약 6분·작성: 해비·블로그 소개

React 객체·배열 state를 바꿔도 화면이 갱신되지 않으면 무엇을 확인하나요?

React 화면이 갱신되지 않으면 기존 객체·배열을 직접 바꾼 뒤 같은 참조를 setter에 넘겼는지 확인합니다. 새 배열과 변경 대상의 새 객체를 만들고, 이전 업데이트 결과가 필요한 계산은 함수형 updater로 작성합니다.

본문 근거: React useState Troubleshooting · Updating Arrays in State

독립 실습: 동일 배열 참조와 새 배열 비교하기

같은 클릭에서 연속 업데이트했을 때 최종 횟수를 확인하세요.

예제 크게 보기 (새 탭)

JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.

src/App.tsx 전체 코드

import { useState } from 'react';
export default function App() {
  const [items, setItems] = useState(['처음']);
  function brokenAdd() {
    items.push('잘못 추가');
    setItems(items);
  }
  function correctAdd() {
    setItems(previous => [...previous, '정상 추가']);
  }
  return (
    <main>
      <p>개수: {items.length}</p>
      <button onClick={brokenAdd}>잘못된 추가</button>
      <button onClick={correctAdd}>올바른 추가</button>
    </main>
  );
}

직접 확인: 새로고침 후 잘못된 추가를 한 번 누르면 화면은 개수: 1을 유지합니다. 다시 새로고침하고 올바른 추가만 누르면 2로 바뀝니다. 두 버튼을 연속 비교하지 말고 각각 초기 상태에서 시작합니다.

변형 과제: setter에 전달하는 값의 참조가 바뀌는 지점을 설명하고 brokenAdd도 함수형 updater로 고칩니다.

배열을 직접 변경하면 왜 문제가 될까

다음 코드는 배열 내용은 바뀌지만 같은 배열 객체를 다시 setter에 전달합니다.

tasks.push(newTask);
setTasks(tasks);

React state는 기존 값을 직접 수정하기보다 새 값을 만들어 교체하는 방식으로 다루는 것이 기본입니다.

setTasks((previousTasks) => [
  ...previousTasks,
  newTask,
]);

객체도 직접 수정하지 않습니다

잘못된 예:

task.completed = true;

수정할 항목만 새 객체로 만듭니다.

setTasks((previousTasks) =>
  previousTasks.map((task) =>
    task.id === id
      ? { ...task, completed: !task.completed }
      : task,
  ),
);

setState 직후 이전 값이 보이는 이유

다음 코드를 보면 setter를 호출한 다음 줄에서 이전 값이 보일 수 있습니다.

setCount(count + 1);
console.log(count);

현재 이벤트 핸들러 안의 count 변수 자체가 즉시 다른 값으로 바뀌는 것은 아닙니다. React는 다음 렌더링에 사용할 state 업데이트를 예약합니다.

따라서 다음 값을 계산할 때 이전 state가 필요하다면 함수형 updater를 사용합니다.

setCount((previous) => previous + 1);

연속 업데이트

다음 코드는 count를 3 증가시키고 싶어도 기대와 다를 수 있습니다.

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

이전 업데이트 결과를 이용하려면 다음처럼 작성합니다.

setCount((value) => value + 1);
setCount((value) => value + 1);
setCount((value) => value + 1);

오류 진단 순서

state가 예상대로 갱신되지 않는다면 다음 순서로 확인합니다.

  1. 객체나 배열을 직접 수정했는지 봅니다.
  2. setter에 기존 객체나 배열을 그대로 다시 전달했는지 봅니다.
  3. 이전 state가 필요한 계산에서 현재 렌더의 값을 사용하고 있는지 봅니다.
  4. state로 저장할 필요가 없는 파생 값을 별도 state로 만들었는지 봅니다.
  5. 문제를 작은 컴포넌트로 줄여 같은 증상이 재현되는지 확인합니다.

학습 완료 체크리스트

  • 배열과 객체 state를 불변 방식으로 바꿀 수 있습니다.
  • setter 직후 이전 값이 보이는 이유를 설명할 수 있습니다.
  • 함수형 updater가 필요한 상황을 구분할 수 있습니다.
  • state 문제를 최소 재현으로 줄여 확인할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.

이번 주제: React state 업데이트 안됨 문제 해결.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 8단계 실행

시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: React useState 사용법 state와 객체 배열 업데이트 기준의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/TaskList.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

변경 순서: src/App.tsx의 handleToggle을 아래 map 기반 함수형 updater로 교체합니다. TaskItem의 클릭 연결은 유지합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.

독자 질문과 직접 확인

질문: 작업 객체의 completed만 직접 바꿨는데 왜 화면이 갱신되지 않을 수 있나요?

컴포넌트 실습: step-08-state-fix의 올바른 업데이트 코드와 이전 배열을 직접 수정하는 오류 예시를 나란히 읽고, 같은 작업을 완료 버튼으로 토글합니다. setTasks에서 map이 변경 대상에만 새 객체를 반환하는지 확인합니다.

예상 화면·상태: 정상 코드에서는 클릭한 항목의 완료 표시만 바뀌고 다른 작업의 id와 표시가 유지됩니다. 잘못된 직접 변경 코드는 React가 이전 참조와 변경을 구분하지 못할 수 있습니다. 이 내용은 독자가 확인할 기준이며 이번 편집에서 앱을 실행하거나 브라우저 화면을 관찰한 결과가 아닙니다.

오류 점검: 연속 클릭 결과가 어긋나면 현재 렌더의 tasks 값을 그대로 쓰는 대신 prev를 받는 함수형 업데이트와 객체 복사 위치를 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.

다음 학습: 09회에서 수정·삭제가 있는 목록의 안정적인 key를 점검합니다.

완료 코드 ZIP 다운로드 — step-08-state-fix

이전 체크포인트

step-07-state

오류 재현

TaskFlow의 완료 처리 코드를 일부러 다음처럼 바꿉니다.

const handleToggle = (id: string) => {
  const target = tasks.find((task) => task.id === id);

  if (!target) {
    return;
  }

  target.completed = !target.completed;
  setTasks(tasks);
};

객체와 배열을 모두 직접 수정하고 있습니다.

수정

map과 새 객체를 사용합니다.

const handleToggle = (id: string) => {
  setTasks((previousTasks) =>
    previousTasks.map((task) =>
      task.id === id
        ? { ...task, completed: !task.completed }
        : task,
    ),
  );
};

실행 확인

작업 추가·완료·삭제를 여러 번 반복해도 목록이 예상대로 바뀌는지 확인합니다.

npm run build

완료 체크포인트:

step-08-state-fix

전체 코드 뷰어의 모든 파일은 다운로드 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-08-state-fix",
  "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: 'state 업데이트 오류 확인하기',
    completed: false,
  },
];

function App() {
  const [tasks, setTasks] = useState<Task[]>(initialTasks);

  const handleAddSample = () => {
    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 />

      <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 (
    <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 state 업데이트 원인 진단 흐름과 새 객체 생성 과정을 설명하는 다이어그램

React · 8/32 · TaskFlow 누적 실습

React state 업데이트 안됨 문제 해결

React · 8 / 32 · 선택 참고 · 오류 해결

화면이 갱신되지 않으면 setter에 기존 배열·객체 참조를 다시 전달했는지 먼저 확인하고, 새 배열과 변경 대상의 새 객체로 교체합니다. setter 직후 이전 값이 보이는 것은 현재 렌더의 값이 즉시 바뀌지 않기 때문이며, 이전 업데이트 결과가 필요한 계산에는 함수형 updater를 씁니다. 대부분은 React가 느리기 때문이 아니라 state를 일반 변수처럼 다루거나 객체·배열을 직접 변경했기 때문에 발생합니다.

상단에서는 state 업데이트 문제만 검색해서 들어온 독자가 원인을 찾을 수 있도록 정리합니다. 하단에서는 TaskFlow에서 잘못된 업데이트를 직접 재현하고 정상 코드로 되돌립니다.

이번 글에서 배우는 내용

  • state를 직접 변경하면 안 되는 이유
  • 같은 배열·객체 참조를 다시 전달할 때 생기는 문제
  • setter 직후 이전 값이 보이는 이유
  • 함수형 updater가 필요한 경우
  • 최소 재현으로 state 문제를 진단하는 방법

목차

다음 학습

다음 글에서는 동적 리스트에서 React가 각 항목을 어떻게 구분하는지 살펴보고 index key를 피해야 하는 기준을 정리합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 33개 · 필수 27개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기