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

2026.05.29·수정 2026.09.12·약 46분·작성: 해비·블로그 소개

먼저 확인할 핵심

React에서 Cannot update a component while rendering a different component 경고가 발생했을 때, 단순히 setState를 없애는 방식이 아니라 호출 위치를 기준으로 원인을 찾는 방법을 정리합니다. 자식 컴포넌트가 부모 상태를 렌더링 중 바꾸는 상황, 이벤트 핸들러로 옮겨야 하는 경우, 초기값 설계로 풀어야 하는 경우, useEffect를 써도 되는 경우를 나누어 봅니다.

경고가 뜨는 상황부터 확인하기

React 렌더링 중 자식 컴포넌트가 부모 상태를 업데이트해 경고가 발생하는 구조

React 작업 중 화면은 일단 보이는데 콘솔에 긴 경고가 찍히는 경우가 있습니다. 그중 Cannot update a component while rendering a different component는 상태 변경 위치가 렌더링 단계와 맞지 않을 때 자주 나옵니다.

이 경고는 setState를 사용했다는 사실만으로 발생하지 않습니다. 버튼 클릭, 입력 변경, 비동기 요청 완료처럼 렌더링 바깥에서 상태를 바꾸는 것은 React에서 흔한 작업입니다. 문제는 컴포넌트가 JSX를 계산하는 도중에 다른 컴포넌트의 상태를 바꾸려고 할 때 생깁니다.

예를 들어 상품 카드 목록에서 첫 번째 상품을 자동 선택하려고 할 때를 생각할 수 있습니다. 부모 컴포넌트는 selectedId를 가지고 있고, 자식 카드 컴포넌트는 상품 정보를 받아 화면에 그립니다. 그런데 자식 컴포넌트가 렌더링되는 본문에서 “선택된 값이 없으면 내 상품을 선택하자”라고 판단해 부모의 setSelectedId를 바로 호출하면 경고가 발생할 수 있습니다.

문제가 되는 코드

import { useState } from 'react';

function ProductList({ products }) {
    const [selectedId, setSelectedId] = useState(null);

    return (
        <ul>
            {products.map((product) => (
                <ProductCard
                    key={product.id}
                    product={product}
                    selectedId={selectedId}
                    setSelectedId={setSelectedId}
                />
            ))}
        </ul>
    );
}

function ProductCard({ product, selectedId, setSelectedId }) {
    if (selectedId === null) {
        setSelectedId(product.id);
    }

    return (
        <li>
            <button>{product.name}</button>
        </li>
    );
}

겉으로 보면 조건문이 있으니 한 번만 실행될 것처럼 보입니다. 하지만 React 입장에서는 ProductCard를 렌더링하는 중에 ProductList의 상태 변경 요청이 들어온 상태입니다. 현재 화면을 계산하는 도중 부모 컴포넌트를 다시 업데이트해야 하므로 렌더링 순서가 불안정해집니다.

렌더링 중 setState가 문제가 되는 이유

React 컴포넌트의 본문은 화면을 계산하는 구간입니다. 이 구간에서는 props, state, context를 읽고 이번 렌더에서 어떤 JSX를 반환할지 결정합니다. 같은 입력이 들어오면 같은 결과를 계산할 수 있어야 렌더링 순서를 React가 안정적으로 관리할 수 있습니다.

그런데 렌더링 본문에서 상태를 바꾸면 상황이 달라집니다. 아직 현재 렌더가 끝나지 않았는데 다시 렌더해야 하는 일이 생깁니다. 같은 컴포넌트 안에서 무조건 상태를 바꾸면 무한 렌더링으로 이어질 수 있고, 다른 컴포넌트의 상태를 바꾸면 이번 글의 경고처럼 “다른 컴포넌트를 렌더링 중 업데이트했다”는 메시지가 나올 수 있습니다.

처음에는 이 차이가 잘 보이지 않습니다. 코드상으로는 setSelectedId 한 줄뿐이고, 조건문 안에 있으니 안전해 보입니다. 하지만 기준은 조건문의 유무가 아니라 그 호출이 어느 단계에서 실행되는지입니다. 컴포넌트 함수 본문에서 실행되면 렌더링 중 실행되는 코드입니다.

상태 변경 위치 판단
버튼 클릭 이벤트 안 사용자 행동 이후 실행되므로 일반적으로 적절합니다.
입력 변경 이벤트 안 폼 값 변경처럼 명확한 원인이 있어 자연스럽습니다.
컴포넌트 함수 본문 렌더링 중 실행되므로 경고나 무한 렌더링 원인이 될 수 있습니다.
useEffect 렌더 후 동기화가 필요할 때만 검토합니다. 무조건 옮기는 해결책은 아닙니다.
초기 useState 계산 함수 처음 state 값을 정하는 목적이면 적절한 선택이 될 수 있습니다.

렌더링 중 setter를 모두 금지하는 규칙으로 외우지는 마세요. React는 현재 렌더링 중인 자기 컴포넌트에서 이전 입력과 현재 입력이 달라졌을 때만 상태를 조정하는 제한된 패턴을 허용합니다. 이 경우 다음 렌더에서는 조건이 거짓이 되어야 하며, 다른 컴포넌트의 상태를 바꿀 수는 없습니다. 이 글의 무조건 호출이나 자식에서 부모를 갱신하는 경우와 구분해야 합니다. 먼저 렌더에서 계산, 이벤트 처리, 초기값, key로 초기화할 수 있는지 검토합니다.

다른 컴포넌트를 업데이트한다는 말의 의미

경고 문구의 핵심은 different component입니다. 예시에서는 ProductCard가 렌더링되고 있는데, 그 안에서 ProductList가 가진 상태를 바꾸고 있습니다. 자식 컴포넌트가 부모의 setter를 props로 받는 구조 자체가 문제는 아닙니다. 문제는 그 setter를 렌더링 본문에서 바로 호출한다는 점입니다.

부모가 자식에게 setSelectedId를 넘기는 패턴은 실제 화면에서 자주 씁니다. 탭 메뉴, 필터 버튼, 상품 카드, 관리자 테이블의 행 선택 같은 UI에서 자식이 클릭되면 부모 state를 바꿔야 합니다. 이때는 자식이 부모 상태를 바꾼다는 구조보다, 그 호출이 사용자 이벤트 이후에 실행되는지가 더 중요합니다.

따라서 이 경고를 보면 먼저 검색해야 할 위치는 return 위쪽입니다. 컴포넌트 본문에 setSomething, dispatch, navigate, 전역 store 업데이트 함수가 조건문과 함께 놓여 있는지 확인합니다. 상태 변경 함수 이름이 직접 보이지 않아도, 내부에서 상태를 바꾸는 헬퍼 함수를 호출하고 있을 수 있습니다.

이벤트 핸들러로 옮겨야 하는 경우

사용자 행동 때문에 상태가 바뀌는 값이라면 이벤트 핸들러로 옮기는 것이 가장 먼저 볼 수정 방향입니다. 상품을 선택한다는 동작은 사용자가 카드를 클릭했을 때 발생하는 일이므로, 렌더링 중 자동으로 처리할 필요가 없습니다.

수정 코드

import { useState } from 'react';

function ProductList({ products }) {
    const [selectedId, setSelectedId] = useState(null);

    return (
        <ul>
            {products.map((product) => (
                <ProductCard
                    key={product.id}
                    product={product}
                    isSelected={selectedId === product.id}
                    onSelect={() => setSelectedId(product.id)}
                />
            ))}
        </ul>
    );
}

function ProductCard({ product, isSelected, onSelect }) {
    return (
        <li>
            <button type="button" onClick={onSelect} aria-pressed={isSelected}>
                {product.name}
            </button>
        </li>
    );
}

이 코드에서는 ProductCard가 렌더링되는 동안 부모 상태를 바꾸지 않습니다. 버튼이 클릭된 뒤 onSelect가 실행되고, 그때 부모의 setSelectedId가 호출됩니다. 같은 setter를 자식에게 넘겨도 호출 타이밍이 달라지면 경고가 사라집니다.

여기서 이름도 같이 정리해두면 좋습니다. 자식에게 setSelectedId를 그대로 넘기기보다 onSelect처럼 이벤트 의미가 드러나는 이름으로 넘기면, 자식 입장에서는 “부모 state를 직접 조작한다”보다 “선택 이벤트를 부모에게 알린다”는 구조가 됩니다.

초기값은 렌더링 중 보정하지 않기

경고가 나는 코드 중에는 사용자 이벤트가 아니라 초기값을 맞추려다 생긴 경우도 많습니다. “목록이 있으면 첫 번째 항목을 기본 선택해야 한다”는 요구사항이 대표적입니다. 이때 자식 컴포넌트가 렌더링 중 부모 state를 보정하게 만들면 구조가 꼬입니다.

초기값이 이미 렌더링 전에 정해질 수 있다면 부모에서 state를 만들 때 처리하는 쪽이 더 단순합니다. 목록이 정적인 값이거나, 컴포넌트가 처음 렌더링될 때 이미 들어와 있는 값이라면 useState 초기 계산 함수를 사용할 수 있습니다.

부모에서 초기 선택값을 정하는 코드

import { useState } from 'react';

function ProductList({ products }) {
    const [selectedId, setSelectedId] = useState(() => {
        return products[0]?.id ?? null;
    });

    return (
        <ul>
            {products.map((product) => (
                <ProductCard
                    key={product.id}
                    product={product}
                    isSelected={selectedId === product.id}
                    onSelect={() => setSelectedId(product.id)}
                />
            ))}
        </ul>
    );
}

이 방식은 “처음부터 어떤 값으로 시작할 것인가”를 부모가 책임집니다. 자식 컴포넌트는 자신을 그리는 일과 클릭 이벤트를 전달하는 일만 맡습니다. 상태의 출처가 한 곳에 모이면 나중에 필터, 정렬, 페이지네이션이 붙어도 문제를 추적하기가 훨씬 쉽습니다.

다만 서버 요청이나 비동기 데이터처럼 처음 렌더링 시점에는 products가 비어 있고 나중에 들어오는 경우도 있습니다. 그때는 초기 계산 함수만으로 해결되지 않을 수 있습니다. 이 경우에도 자식 렌더링 본문에서 보정하기보다, 데이터를 받는 부모 쪽에서 조건을 명확하게 잡아 처리해야 합니다.

useEffect로 옮겨도 되는 경우와 조심할 점

Effect는 커밋된 UI를 외부 시스템과 동기화하는 곳입니다. 브라우저가 화면을 그린 뒤에만 실행된다고 단정할 수는 없습니다. 경고를 없애려고 모든 setter를 Effect로 옮기면 추가 렌더나 새로운 루프를 만들 수 있습니다.

비동기로 products가 도착해도 선택값은 현재 목록과 사용자의 선택 ID로 계산할 수 있습니다. 아래 정책은 명시적인 선택이 유효하면 유지하고, 없으면 첫 번째 항목을 기본 표시합니다. 빈 목록이면 null입니다. Effect로 첫 항목 ID를 다시 저장할 필요가 없습니다.

function ProductList({ products }) {
    const [requestedId, setRequestedId] = useState(null);
    const selectedProduct = products.find(product => product.id === requestedId)
        ?? products[0] ?? null;

    return <ul>{products.map(product => (
        <ProductCard key={product.id} product={product}
            isSelected={selectedProduct?.id === product.id}
            onSelect={() => setRequestedId(product.id)} />
    ))}</ul>;
}

이 정책은 사용자가 일부러 아무것도 선택하지 않은 상태와 아직 선택하지 않은 상태를 구별하지 않습니다. 선택 해제가 요구되면 별도 상태 표현을 설계합니다. 목록에서 사라진 요청 ID를 다시 표시할지, 삭제 이벤트에서 초기화할지도 결정합니다. 반면 외부 위젯의 선택 상태나 브라우저 이벤트를 React와 맞추는 동기화에는 Effect와 대응하는 cleanup을 사용할 수 있습니다.

반대로 단순히 props를 가공해서 보여주는 정도라면 effect와 state를 추가하지 않는 편이 낫습니다. 예를 들어 상품 목록을 정렬하거나 필터링한 결과를 화면에 보여주는 일은 렌더링 중 계산해도 됩니다. 상태 변경이 아니라 값 계산이기 때문입니다.

state로 옮기지 않아도 되는 계산

function ProductList({ products, keyword }) {
    const filteredProducts = products.filter((product) => {
        return product.name.includes(keyword);
    });

    return (
        <ul>
            {filteredProducts.map((product) => (
                <li key={product.id}>{product.name}</li>
            ))}
        </ul>
    );
}

이 코드는 렌더링 중 실행되지만 문제가 되지 않습니다. filteredProducts는 새로운 상태를 만들지 않고, 현재 props를 기준으로 이번 렌더에서 보여줄 값을 계산할 뿐입니다. 이 차이를 구분하면 불필요한 useEffect와 state를 줄일 수 있습니다.

무한 렌더링 오류와 이 경고의 차이

Maximum update depth exceeded와 이번 경고는 서로 이어질 수 있지만 완전히 같은 문제는 아닙니다. 무한 렌더링 오류는 상태 변경이 반복되어 React가 더 이상 렌더를 진행하기 어렵다고 판단했을 때 나옵니다. 이번 경고는 렌더링 중 다른 컴포넌트를 업데이트했다는 타이밍 문제에 더 초점이 있습니다.

예를 들어 같은 컴포넌트 본문에서 매번 setCount(count + 1)를 호출하면 현재 컴포넌트가 계속 다시 렌더링됩니다. 반면 자식 컴포넌트 본문에서 부모의 setter를 호출하면 “자식 렌더링 중 부모를 업데이트했다”는 경고가 먼저 보일 수 있습니다. 결국 둘 다 렌더링 중 상태 변경이라는 공통점을 가지지만, 콘솔 메시지가 가리키는 문제 지점은 조금 다릅니다.

그래서 디버깅할 때는 경고 문구에 나온 컴포넌트 이름을 먼저 봐야 합니다. Cannot update a component (Parent) while rendering a different component (Child)처럼 표시된다면, Child 내부에서 Parent 상태를 바꾸는 호출을 찾는 것이 출발점입니다.

다시 발생하지 않게 확인할 체크포인트

React setState 호출 위치를 이벤트 핸들러 useEffect 초기값으로 구분하는 체크리스트

이 경고를 다시 만나면 먼저 setState를 지우려고 하기보다 위치를 확인해야 합니다. 컴포넌트 함수 본문, JSX를 반환하기 전 조건문, map 안에서 바로 실행되는 함수, props로 받은 setter를 즉시 호출하는 코드가 있는지 살펴봅니다.

특히 자식 컴포넌트에서 부모의 setter를 props로 받았다면 호출 위치를 나눠서 봐야 합니다. 클릭, 변경, 제출 같은 이벤트 안에서 호출한다면 자연스러운 구조입니다. 반대로 컴포넌트 본문에서 조건문과 함께 호출한다면 렌더링 중 부모 state를 건드리는 코드일 가능성이 큽니다.

초기값을 맞추려는 코드도 자주 문제를 만듭니다. 목록의 첫 번째 값을 자동 선택해야 한다면 부모의 초기 state 또는 현재 목록과 사용자가 선택한 ID로 계산할 수 있는지부터 비교해야 합니다. 실제 외부 시스템 동기화가 있을 때 Effect를 검토합니다. 렌더링 중 보정하는 방식은 당장은 짧아 보여도 컴포넌트가 늘어나면 경고를 추적하기 어려워집니다.

마지막으로 useEffect로 옮겼다고 끝난 것은 아닙니다. useEffect 안에 상태 변경이 있다면 의존성 배열과 종료 조건을 같이 확인해야 합니다. 조건 없이 useEffect에서 상태를 바꾸면 경고는 사라져도 추가 렌더링이나 다른 루프가 생길 수 있습니다.

마무리 점검

Cannot update a component while rendering a different component 경고는 상태 변경 자체보다 호출 타이밍을 보라는 신호입니다. React가 컴포넌트를 렌더링하는 동안 다른 컴포넌트의 상태를 바꾸면, 현재 화면 계산이 끝나기 전에 새로운 업데이트가 끼어들게 됩니다.

사용자 행동으로 바뀌는 값은 이벤트 핸들러에 두고, 처음부터 정해질 수 있는 값은 부모의 초기 state에서 처리하는 것이 좋습니다. 나중에 들어온 데이터도 표시용 선택값을 계산하는 입력이 될 수 있습니다. 외부 시스템과 동기화할 때만 Effect의 의존성과 cleanup을 검토합니다.

다음에 같은 경고가 나오면 콘솔에 표시된 컴포넌트 이름을 기준으로 찾아가면 됩니다. 렌더링 중인 컴포넌트 안에서 부모나 다른 컴포넌트의 setter를 바로 호출하고 있는지 확인하는 것만으로도 원인의 상당 부분을 좁힐 수 있습니다.

직접 실행하는 최소 재현과 수정 실습

실습 ZIP 다운로드 · 6518번 글의 완성 프로젝트입니다. Node.js 22.12 이상에서 압축을 풀고 아래 명령을 실행합니다. HTML, CSS, JSX가 별도 파일이며 외부 API는 사용하지 않습니다.

npm install
npm run dev

# 결과 검증
npm test
npm run build
npm run preview

1. 원인을 확인하는 최소 오류 코드

function ProductCard({ product, selectedId, setSelectedId }) {
  if (selectedId === null) setSelectedId(product.id);
  return <li>{product.name}</li>;
}

오류 조각은 ProductCard 렌더 도중 부모의 setter를 실행합니다. null 조건이 있어도 다른 컴포넌트를 업데이트한다는 문제는 남습니다. 수정본에서는 자식이 클릭 때 onSelect만 호출하고, 부모가 요청한 ID가 없거나 목록에서 사라지면 첫 항목을 표시용 기본값으로 계산합니다. 목록 불러오기 버튼은 서버 응답으로 데이터가 도착하는 시점을 로컬 데이터로 재현합니다. 네트워크 요청이나 선택값 동기화 Effect는 필요하지 않습니다.

2. App.jsx 수정본

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

이 실습은 오류를 재현하는 시작본과 설명, 검증된 완성본을 차례로 비교합니다. 목록을 불러온 뒤 공책을 선택하고 선택 항목을 두 번 삭제하세요. 완성본은 연필, 공책, 연필, 없음 순서를 유지합니다.

수정 전

App.jsx

import React, { useState } from 'react';

const sample = [
  { id: 'p', name: '연필' },
  { id: 'b', name: '공책' },
];

function ProductCard({ product, selectedId, setSelectedId }) {
  if (selectedId === null) setSelectedId(product.id);
  return (
    <li>
      <button
        type="button"
        aria-pressed={selectedId === product.id}
        onClick={() => setSelectedId(product.id)}
      >
        {product.name}
      </button>
    </li>
  );
}

export default function App() {
  const [products, setProducts] = useState([]);
  const [selectedId, setSelectedId] = useState(null);
  const selected = products.find((product) => product.id === selectedId) ?? null;
  function removeSelected() {
    setProducts((previous) => previous.filter((product) => product.id !== selected.id));
    setSelectedId(null);
  }
  return (
    <main>
      <h1>부모가 관리하는 상품 선택</h1>
      <p>목록 도착을 버튼으로 재현합니다. 기본 선택은 렌더에서 계산합니다.</p>
      <button type="button" onClick={() => setProducts(sample)}>
        목록 불러오기
      </button>
      <ul>
        {products.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            selectedId={selectedId}
            setSelectedId={setSelectedId}
          />
        ))}
      </ul>
      <p role="status">선택: {selected?.name ?? '없음'}</p>
      <button type="button" disabled={!selected} onClick={removeSelected}>
        선택 항목 삭제
      </button>
    </main>
  );
}

App.test.jsx

import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('derives fallback for arriving data and handles child selection and removal', async () => {
  const user = userEvent.setup();
  render(<App />);
  expect(screen.getByRole('status').textContent).toBe('선택: 없음');
  await user.click(screen.getByRole('button', { name: '목록 불러오기' }));
  expect(screen.getByRole('status').textContent).toBe('선택: 연필');
  await user.click(screen.getByRole('button', { name: '공책' }));
  expect(
    screen.getByRole('button', { name: '공책' }).getAttribute('aria-pressed'),
  ).toBe('true');
  expect(screen.getByRole('status').textContent).toBe('선택: 공책');
  await user.click(screen.getByRole('button', { name: '선택 항목 삭제' }));
  expect(screen.getByRole('status').textContent).toBe('선택: 연필');
  await user.click(screen.getByRole('button', { name: '선택 항목 삭제' }));
  expect(screen.getByRole('status').textContent).toBe('선택: 없음');
});

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습 6518</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.jsx"></script>
  </body>
</html>

main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

package.json

{
  "name": "react-practice-6518",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
h1 {
  font-size: 1.7rem;
}
label {
  display: block;
  margin: 12px 0 6px;
}
input,
select,
button {
  font: inherit;
  color: #222;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
}
input:not([type='checkbox']),
select {
  max-width: 100%;
}
button {
  margin: 8px 8px 8px 0;
  cursor: pointer;
}
button:disabled {
  color: #777;
  cursor: default;
}
[aria-pressed='true'] {
  background: #222;
  color: #fff;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 12px 0;
}
[role='alert'] {
  font-weight: 700;
}

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });

수정 방법

App.jsx

수정 위치: ProductCard가 렌더되는 동안 selectedId가 null이면 부모 setSelectedId를 호출하는 부분을 확인하세요.

이유: 렌더는 순수해야 하며 자식 렌더 중 부모 state를 바꾸면 경고와 추가 렌더를 일으킵니다. 사용자가 누를 때만 requestedId를 저장하고, 데이터 도착·삭제 뒤 기본 선택은 products에서 계산합니다.

재현과 기대 결과: 목록을 불러온 뒤 공책을 선택하고 선택 항목을 두 번 삭제하세요. 완성본은 연필, 공책, 연필, 없음 순서를 유지합니다.

App.jsx에서 아래 차이가 있는 위치를 수정하세요. -는 삭제할 줄, +는 추가할 줄이며 표시는 실제 코드에 넣지 않습니다.

본문의 확인 절차로 변경 결과를 확인합니다.

--- 수정 전/App.jsx
+++ 수정 후/App.jsx
@@ -5,15 +5,10 @@
   { id: 'b', name: '공책' },
 ];
 
-function ProductCard({ product, selectedId, setSelectedId }) {
-  if (selectedId === null) setSelectedId(product.id);
+function ProductCard({ product, selected, onSelect }) {
   return (
     <li>
-      <button
-        type="button"
-        aria-pressed={selectedId === product.id}
-        onClick={() => setSelectedId(product.id)}
-      >
+      <button type="button" aria-pressed={selected} onClick={onSelect}>
         {product.name}
       </button>
     </li>
@@ -22,11 +17,12 @@
 
 export default function App() {
   const [products, setProducts] = useState([]);
-  const [selectedId, setSelectedId] = useState(null);
-  const selected = products.find((product) => product.id === selectedId) ?? null;
+  const [requestedId, setRequestedId] = useState(null);
+  const selected =
+    products.find((product) => product.id === requestedId) ?? products[0] ?? null;
   function removeSelected() {
     setProducts((previous) => previous.filter((product) => product.id !== selected.id));
-    setSelectedId(null);
+    setRequestedId(null);
   }
   return (
     <main>
@@ -40,8 +36,8 @@
           <ProductCard
             key={product.id}
             product={product}
-            selectedId={selectedId}
-            setSelectedId={setSelectedId}
+            selected={selected?.id === product.id}
+            onSelect={() => setRequestedId(product.id)}
           />
         ))}
       </ul>

수정 후 탭의 전체 파일과 비교한 뒤 본문의 확인 절차를 실행하세요.

App.test.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

index.html

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

main.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

package.json

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

styles.css

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

vite.config.js

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

수정 후

App.jsx

import React, { useState } from 'react';

const sample = [
  { id: 'p', name: '연필' },
  { id: 'b', name: '공책' },
];

function ProductCard({ product, selected, onSelect }) {
  return (
    <li>
      <button type="button" aria-pressed={selected} onClick={onSelect}>
        {product.name}
      </button>
    </li>
  );
}

export default function App() {
  const [products, setProducts] = useState([]);
  const [requestedId, setRequestedId] = useState(null);
  const selected =
    products.find((product) => product.id === requestedId) ?? products[0] ?? null;
  function removeSelected() {
    setProducts((previous) => previous.filter((product) => product.id !== selected.id));
    setRequestedId(null);
  }
  return (
    <main>
      <h1>부모가 관리하는 상품 선택</h1>
      <p>목록 도착을 버튼으로 재현합니다. 기본 선택은 렌더에서 계산합니다.</p>
      <button type="button" onClick={() => setProducts(sample)}>
        목록 불러오기
      </button>
      <ul>
        {products.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            selected={selected?.id === product.id}
            onSelect={() => setRequestedId(product.id)}
          />
        ))}
      </ul>
      <p role="status">선택: {selected?.name ?? '없음'}</p>
      <button type="button" disabled={!selected} onClick={removeSelected}>
        선택 항목 삭제
      </button>
    </main>
  );
}

App.test.jsx

import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('derives fallback for arriving data and handles child selection and removal', async () => {
  const user = userEvent.setup();
  render(<App />);
  expect(screen.getByRole('status').textContent).toBe('선택: 없음');
  await user.click(screen.getByRole('button', { name: '목록 불러오기' }));
  expect(screen.getByRole('status').textContent).toBe('선택: 연필');
  await user.click(screen.getByRole('button', { name: '공책' }));
  expect(
    screen.getByRole('button', { name: '공책' }).getAttribute('aria-pressed'),
  ).toBe('true');
  expect(screen.getByRole('status').textContent).toBe('선택: 공책');
  await user.click(screen.getByRole('button', { name: '선택 항목 삭제' }));
  expect(screen.getByRole('status').textContent).toBe('선택: 연필');
  await user.click(screen.getByRole('button', { name: '선택 항목 삭제' }));
  expect(screen.getByRole('status').textContent).toBe('선택: 없음');
});

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습 6518</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.jsx"></script>
  </body>
</html>

main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

package.json

{
  "name": "react-practice-6518",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
h1 {
  font-size: 1.7rem;
}
label {
  display: block;
  margin: 12px 0 6px;
}
input,
select,
button {
  font: inherit;
  color: #222;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
}
input:not([type='checkbox']),
select {
  max-width: 100%;
}
button {
  margin: 8px 8px 8px 0;
  cursor: pointer;
}
button:disabled {
  color: #777;
  cursor: default;
}
[aria-pressed='true'] {
  background: #222;
  color: #fff;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 12px 0;
}
[role='alert'] {
  font-weight: 700;
}

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });

App.jsx 전체 코드 보기

3. 따라 해볼 순서와 완료 기준

목록 불러오기 → 공책 → 선택 항목 삭제 → 선택 항목 삭제. 선택은 없음 → 연필 → 공책 → 연필 → 없음 순서입니다.

ZIP의 main.jsx에서 React 루트와 StrictMode를 구성하고 styles.css에서 흑백 UI를 적용합니다. App.test.jsx는 실제 입력·클릭 뒤 화면 결과를 확인합니다. 오류 코드는 broken-example.md에 설명용으로 보관되어 자동 실행되지 않습니다. 테스트는 jsdom에서 검증하며 브라우저의 시각적 배치까지 검증한 것은 아닙니다.

공식 참고 자료

이 글이 도움이 되었나요?

조회 중

React 학습 순서

필수 19개 · 전체 23개

읽음 기록 관리

전체 과정 목차 (23개)
  1. 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
  2. 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
  3. 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
  4. 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
  5. 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
  6. 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
  7. 선택 참고 · React state 업데이트 안됨 문제 해결
  8. 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
  9. 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
  10. 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
  11. 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
  12. 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
  13. 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인 현재 글
  14. 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
  15. 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
  16. 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
  17. 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
  18. 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
  19. 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
  20. 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
  21. 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
  22. 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
  23. 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기