React Cannot update 오류 해결 렌더링 중 setState 원인

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

React 렌더링 중 Cannot update 경고가 나면 setState를 어디서 호출해야 하나요?

렌더링 도중 다른 컴포넌트의 state를 바꾸면 Cannot update 경고가 날 수 있습니다. 렌더링은 JSX 계산에 두고 상태 변경은 클릭 같은 이벤트에서 실행합니다. 부모 setter를 자식이 언제 호출하는지 비교합니다.

본문 근거: Keeping Components Pure

독립 실습: 부모 setter를 자식 클릭으로만 호출하기

자식의 이벤트로 부모 카운터를 업데이트하세요.

예제 크게 보기 (새 탭)

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

src/App.tsx 전체 코드

import { useState } from 'react';
function Child({ count, onChange }: { count: number; onChange: (value: number) => void }) {
  return <button onClick={() => onChange(count + 1)}>변경</button>;
}
export default function App() {
  const [count, setCount] = useState(0);
  return (
    <main>
      <p>{count}</p>
      <Child count={count} onChange={setCount} />
    </main>
  );
}

직접 확인: 첫 화면은 0, 클릭 후 1입니다. 개발 Console에 렌더링 중 다른 컴포넌트 갱신 경고가 없어야 합니다.

변형 과제: 오류를 재현하려면 Child return 위에 if (count === 0) onChange(1);을 잠시 추가하고 새로고침해 경고를 확인한 뒤 그 줄을 제거합니다.

최소 재현

function Child({
  setCount,
}: {
  setCount: (value: number) => void;
}) {
  setCount(1);

  return <p>자식 컴포넌트</p>;
}

Child가 렌더링되는 순간 부모의 state를 바꾸고 있습니다.

렌더링은 계산만 해야 하므로 상태 변경은 이벤트 핸들러 등 적절한 시점으로 이동합니다.

function Child({
  setCount,
}: {
  setCount: (value: number) => void;
}) {
  return (
    <button onClick={() => setCount(1)}>
      변경
    </button>
  );
}

함수 props를 즉시 실행하지 않습니다

잘못된 코드:

<button onClick={onToggle(task.id)}>
  완료
</button>

onToggle의 결과를 onClick에 전달하는 형태이므로 렌더링 중 함수가 실행됩니다.

다음처럼 함수 자체를 전달합니다.

<button onClick={() => onToggle(task.id)}>
  완료
</button>

Effect로 옮기면 항상 해결될까

사용자 클릭 때문에 발생해야 하는 상태 변경을 Effect로 옮길 필요는 없습니다.

  • 사용자 동작 때문에 발생 → 이벤트 핸들러
  • 렌더링 결과와 외부 시스템을 맞춤 → Effect
  • JSX 계산 → 렌더링

이 세 영역을 먼저 구분합니다.

자주 발생하는 오류와 해결

조건이 맞으면 렌더 중 setState를 호출합니다

if (tasks.length === 0) {
  setMessage('비어 있음');
}

message가 tasks에서 계산 가능한 값이라면 별도 state 자체가 필요 없는지 먼저 확인합니다.

자식이 부모 setter를 바로 호출합니다

이벤트 핸들러로 이동합니다.

학습 완료 체크리스트

  • 렌더링이 순수해야 하는 이유를 설명할 수 있습니다.
  • 함수 props 즉시 실행과 이벤트 함수 전달을 구분할 수 있습니다.
  • 이벤트와 Effect 중 어떤 위치가 적절한지 판단할 수 있습니다.

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

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

TaskItem의 완료 상태 변경을 렌더 본문에서 클릭 이벤트로 옮깁니다.

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

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

TaskFlow 프로젝트 적용

TaskFlow 17단계 실행

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

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: React Maximum update depth exceeded 오류 해결 무한 렌더링 원인 찾기의 완료 상태에서 이어갑니다.

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

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

변경 순서: src/components/TaskItem.tsx의 렌더 본문에서 onToggle 즉시 호출을 제거하고 완료 버튼의 onClick 함수 안으로 이동합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.

독자 질문과 직접 확인

질문: 자식 컴포넌트를 그리는 도중 부모의 setState를 호출하면 왜 경고가 나오나요?

컴포넌트 실습: step-17-render-state-fix에서 완료 버튼을 누르기 전후의 UI와 Console 경고를 확인합니다. onClick에 전달한 함수가 렌더링 중 즉시 실행되는지, 클릭할 때만 실행되는지 비교합니다.

예상 화면·상태: 완료 상태는 클릭 이후에만 바뀌고 렌더링 시 다른 컴포넌트를 갱신한다는 경고가 없어야 합니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.

오류 점검: 경고가 반복되면 onClick={onToggle(id)}처럼 렌더 중 호출하는 코드와 컴포넌트 본문에 놓인 setState를 먼저 찾습니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.

다음 학습: 18회에서 작업 변경 규칙을 reducer로 모으고 Context로 공유합니다.

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

이전 체크포인트

step-16-effect-loop-fix

오류 재현

TaskItem을 다음처럼 잘못 작성해 봅니다.

if (!task.completed) {
  onToggle(task.id);
}

TaskItem이 렌더링되는 동안 부모의 tasks state 변경을 요청하게 됩니다.

수정

상태 변경을 사용자의 이벤트로 이동합니다.

<button
  type="button"
  onClick={() => onToggle(task.id)}
>
  {task.completed ? '완료 취소' : '완료'}
</button>

App.tsx의 state 업데이트 함수는 step-16과 동일하게 유지합니다. 이번 단계의 핵심은 새 기능 추가가 아니라 상태 변경 시점을 렌더링에서 이벤트로 옮기는 것입니다.

실행 확인

브라우저 Console에 경고가 없는지 확인하고 완료 버튼을 클릭했을 때만 상태가 바뀌는지 확인합니다.

완료 체크포인트:

step-17-render-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-17-render-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 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: '무한 Effect 원인 찾기',
    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 TaskCard from './TaskCard';
import type { Task } from '../types/task';

type TaskItemProps = {
  task: Task;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
};

export default function TaskItem({
  task,
  onToggle,
  onDelete,
}: TaskItemProps) {
  return (
    <TaskCard>
      <button
        type="button"
        onClick={() => onToggle(task.id)}
      >
        {task.completed ? '완료 취소' : '완료'}
      </button>

      <span>{task.title}</span>

      <button
        type="button"
        onClick={() => onDelete(task.id)}
      >
        삭제
      </button>
    </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 렌더링 중 자식 컴포넌트가 부모 상태를 업데이트해 경고가 발생하는 구조

React · 17/32 · TaskFlow 누적 실습

React Cannot update 오류 해결 렌더링 중 setState 원인

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

React 컴포넌트의 렌더링은 props와 state를 읽고 JSX를 계산하는 순수한 과정이어야 합니다. 렌더링 도중 다른 컴포넌트의 state를 바꾸면 “Cannot update a component while rendering a different component” 경고를 만날 수 있습니다.

핵심은 setState 자체가 문제가 아니라 언제 상태를 변경하느냐입니다.

이번 글에서 배우는 내용

  • 렌더링 순수성
  • 렌더 중 setState가 문제인 이유
  • 이벤트 핸들러와 렌더링 코드의 차이
  • Effect로 옮겨야 하는 경우와 이벤트로 옮겨야 하는 경우
  • 함수 props를 즉시 실행하는 실수

목차

다음 학습

지금까지 App이 작업 상태와 여러 변경 함수를 모두 소유했습니다. 다음 글에서는 useReducer와 Context를 이용해 상태 변경 규칙을 한곳에 모으고 깊은 props 전달을 줄입니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기