React state 업데이트가 화면에 안 보일 때 확인할 것
React에서 state를 바꿨는데 화면이 그대로라면 먼저 기존 배열이나 객체를 직접 수정했는지, 같은 참조를 다시 넘겼는지, setState 직후 값을 최신 값으로 착각하고 있는지부터 봐야 합니다. React는 다음 state가 이전 state와 Object.is 기준으로 같으면 렌더링을 건너뛸 수 있으므로, 화면이 바뀌지 않는 문제는 코드가 실행되지 않아서라기보다 새 상태로 판단할 수 없는 값이 전달될 때 자주 생깁니다.
- 화면이 안 바뀌는 증상부터 분리하기
- 배열과 객체를 직접 바꿨는지 확인하기
- setState 직후 로그를 해석하는 법
- 함수형 업데이트가 필요한 상황
- 중첩 객체와 같은 참조 문제
- 중복 state 때문에 화면이 어긋나는 경우
- 수정 후 점검 체크리스트
- 정리
화면이 안 바뀌는 증상부터 분리하기

React에서 버튼을 눌렀는데 숫자가 그대로이거나, 체크박스를 선택했는데 목록 UI가 바뀌지 않거나, 장바구니 배열에 상품을 넣었는데 화면에는 이전 목록이 보이는 경우가 있습니다. 이런 문제는 처음 보면 이벤트가 실행되지 않은 것처럼 느껴집니다. 하지만 실제로는 클릭 함수가 실행됐고 콘솔에도 값이 찍히는데, 렌더링 결과만 기대와 다르게 남아 있는 경우가 많습니다.
이때 바로 useEffect를 추가하거나 강제로 새로고침하는 식으로 해결하려고 하면 원인을 놓치기 쉽습니다. 먼저 문제를 세 단계로 나눠보는 것이 좋습니다. 이벤트 핸들러가 호출됐는지, state setter가 호출됐는지, setter에 전달한 값이 React가 새 값으로 볼 수 있는 형태인지 순서대로 확인합니다.
여기서 핵심은 “값이 바뀐 것처럼 보인다”와 “React가 새 상태를 받았다”가 같지 않다는 점입니다. JavaScript 배열과 객체는 참조 타입입니다. 기존 배열 안에 값을 밀어 넣거나 기존 객체의 속성을 직접 바꾸면 콘솔에서는 값이 바뀐 것처럼 보일 수 있습니다. 하지만 React state에서는 기존 값을 직접 고치는 방식보다 새 배열이나 새 객체를 만들어 넘기는 방식이 기본입니다.
실무에서는 이 문제가 폼, 필터, 탭, 모달, 체크 리스트, 장바구니 UI에서 자주 나옵니다. 예를 들어 이벤트 페이지에서 필터를 눌렀는데 선택된 칩 UI가 갱신되지 않거나, 관리자 화면에서 상태값을 변경했는데 테이블 행이 이전 값으로 보이는 식입니다. 단순히 “React가 느리다”거나 “렌더링이 안 됐다”로 묶기보다 state가 어떤 형태로 업데이트되는지부터 좁혀야 합니다.
배열과 객체를 직접 바꿨는지 확인하기
가장 흔한 원인은 기존 state를 직접 수정하는 코드입니다. 배열에서는 push, pop, splice, 직접 인덱스 대입이 대표적이고, 객체에서는 user.name = '값'처럼 속성을 직접 바꾸는 코드가 해당됩니다. JavaScript 문법만 놓고 보면 문제 없는 코드지만, React state로 관리하는 값에는 맞지 않는 방식입니다.
const [items, setItems] = useState<string[]>([]);
function addItem(name: string) {
items.push(name);
setItems(items);
}
위 코드는 items 배열에 새 값이 들어간 것처럼 보입니다. 실제로 console.log(items)를 찍어보면 값이 추가되어 보일 수도 있습니다. 그러나 setItems(items)로 다시 넘긴 값은 기존 배열과 같은 참조입니다. React의 useState 기준에서는 다음 값이 이전 값과 Object.is로 같으면 업데이트가 무시될 수 있으므로, 코드가 커질수록 화면 갱신과 변경 추적이 모두 어려워집니다.
const [items, setItems] = useState<string[]>([]);
function addItem(name: string) {
setItems((prevItems) => [...prevItems, name]);
}
수정한 코드는 이전 배열을 직접 바꾸지 않습니다. prevItems를 펼쳐 새 배열을 만들고, 마지막에 새 값을 추가합니다. 이처럼 state 업데이트 코드는 “기존 값을 바꾸기”가 아니라 “기존 값을 참고해서 다음 값을 만들기”로 보는 편이 안전합니다.
삭제와 수정도 같은 기준입니다
삭제할 때도 기존 배열을 잘라내기보다 새 배열을 반환해야 합니다. splice는 원본 배열을 바꾸지만, filter는 조건에 맞는 새 배열을 반환합니다. 항목 수정은 map으로 전체 배열을 새로 만들고, 실제로 바뀌는 항목만 새 객체로 교체하는 방식이 일반적입니다.
type Todo = {
id: number;
title: string;
done: boolean;
};
const [todos, setTodos] = useState<Todo[]>([]);
function removeTodo(id: number) {
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
}
function toggleTodo(id: number) {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}
여기서 removeTodo는 삭제 대상이 아닌 항목만 남긴 새 배열을 만듭니다. toggleTodo는 모든 항목을 순회하되, 클릭한 항목만 새 객체로 바꿉니다. 나머지 항목은 그대로 반환해도 됩니다. 중요한 것은 실제로 바뀌는 항목이 기존 객체를 직접 수정한 결과가 아니라 새 객체라는 점입니다.
정렬 코드도 원본 배열을 바꾸기 쉽습니다
목록 정렬 기능을 만들 때도 비슷한 문제가 나옵니다. sort는 원본 배열을 직접 정렬합니다. 그래서 state 배열에 바로 sort를 호출하면 기존 state를 바꾸는 코드가 됩니다.
function sortByTitle() {
setTodos((prevTodos) =>
[...prevTodos].sort((a, b) => a.title.localeCompare(b.title))
);
}
정렬이 필요하면 먼저 [...prevTodos]로 새 배열을 만든 뒤 그 배열을 정렬합니다. 필터, 검색, 정렬이 섞인 화면에서는 이런 작은 차이가 중요합니다. 화면이 한 번은 바뀌다가 특정 조건에서만 꼬이는 문제는 대체로 원본 배열을 건드린 코드가 남아 있을 때 많이 발생합니다.
setState 직후 로그를 해석하는 법
setState를 호출한 바로 다음 줄에서 state를 출력했을 때 이전 값이 보이는 경우가 있습니다. 이때 “업데이트가 실패했다”고 판단하면 불필요한 수정을 하게 됩니다. React 공식 문서 기준으로 setter는 현재 실행 중인 함수의 state 변수를 즉시 바꾸는 API가 아니라, 다음 렌더링에 사용할 업데이트를 예약하는 API로 이해해야 합니다.
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count);
}
버튼을 눌러도 위 로그에는 이전 count가 찍힐 수 있습니다. 이 로그는 “다음 렌더링의 count”가 아니라 현재 렌더링에서 만들어진 handleClick 함수가 기억하고 있는 count를 보여줍니다. 그래서 이 로그만 보고 업데이트 실패 여부를 판단하면 안 됩니다.
값이 실제로 바뀌었는지 확인하려면 렌더링된 UI를 보거나, state가 바뀐 뒤 실행되는 useEffect에서 확인하는 편이 더 명확합니다.
const [count, setCount] = useState(0);
useEffect(() => {
console.log('렌더링 후 count:', count);
}, [count]);
function handleClick() {
setCount((prevCount) => prevCount + 1);
}
useEffect 안의 로그는 count가 반영된 렌더링 이후에 실행됩니다. 디버깅 중이라면 클릭 함수 내부 로그와 렌더링 이후 로그를 구분해서 봐야 합니다. 클릭 함수 안에서는 “setter가 호출됐는지”를 보고, useEffect에서는 “렌더링 이후 값이 어떻게 되었는지”를 보는 식으로 역할을 나누면 혼란이 줄어듭니다.
함수형 업데이트가 필요한 상황
현재 state를 기준으로 다음 state를 만들 때는 함수형 업데이트를 우선 고려해야 합니다. 특히 한 이벤트 안에서 같은 state를 여러 번 바꾸거나, 지연 콜백에서 이전 값을 기준으로 누적하는 기능에서는 setState(value)보다 setState(prev => next) 형태가 더 안정적입니다.
const [count, setCount] = useState(0);
function increaseThreeTimes() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
이 코드는 세 번 증가할 것처럼 보이지만, 현재 렌더링의 count 값을 기준으로 같은 계산을 반복합니다. count가 0인 렌더링에서 만들어진 함수라면 세 줄 모두 setCount(1)과 비슷한 의미가 됩니다. 그래서 기대와 달리 한 번만 증가한 것처럼 보일 수 있습니다.
function increaseThreeTimes() {
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
}
서로 분리된 의도적인 클릭은 React가 다음 클릭 전에 상태를 반영하므로 단순히 빠른 클릭이라는 이유만으로 이전 값이 남는다고 단정하지 않습니다. 함수형 업데이트는 React가 큐에 쌓인 이전 업데이트 결과를 다음 계산의 입력값으로 넘겨줍니다. 그래서 같은 이벤트 안에서 여러 번 업데이트하더라도 이전 결과를 이어받아 계산할 수 있습니다. 단순 카운터뿐 아니라 선택 목록 누적, 최근 검색어 추가, 토글 상태 전환처럼 이전 state에 의존하는 코드에서 같은 기준을 적용할 수 있습니다.
중첩 객체와 같은 참조 문제
객체 state에서는 겉으로 새 객체를 만든 것처럼 보이지만, 실제로는 안쪽 객체를 직접 바꾸는 문제가 자주 생깁니다. 사용자 정보, 설정값, 폼 데이터처럼 depth가 있는 상태에서 특히 많이 나타납니다. 이 경우에는 최상위 객체만 새로 만드는 것으로 충분하지 않을 수 있고, 바뀌는 depth마다 새 객체를 만들어야 합니다.
type Profile = {
name: string;
settings: {
theme: 'light' | 'dark';
alarm: boolean;
};
};
const [profile, setProfile] = useState<Profile>({
name: 'Haebi',
settings: {
theme: 'light',
alarm: true,
},
});
function changeTheme() {
profile.settings.theme = 'dark';
setProfile(profile);
}
위 코드는 profile.settings.theme를 직접 바꾸고 같은 profile 객체를 다시 넘깁니다. 콘솔에서는 값이 바뀐 것처럼 보일 수 있지만, React가 받는 최상위 객체 참조는 그대로입니다. 무엇보다 기존 객체를 직접 수정했기 때문에 이전 상태를 추적하기도 어려워집니다.
function changeTheme() {
setProfile((prevProfile) => ({
...prevProfile,
settings: {
...prevProfile.settings,
theme: 'dark',
},
}));
}
이 코드는 최상위 profile도 새 객체로 만들고, 내부의 settings도 새 객체로 만듭니다. 변경 대상인 theme만 새 값으로 교체하고, 나머지 값은 기존 값을 복사합니다. 실제 프로젝트에서 설정 화면이나 회원 정보 수정 폼을 만들 때 이 패턴을 놓치면 일부 필드만 업데이트되지 않거나 다른 필드가 사라지는 문제가 생길 수 있습니다.
폼 state에서는 필드 누락도 함께 확인합니다
객체 state를 업데이트할 때 기존 필드를 복사하지 않으면 다른 값이 사라지는 문제도 생깁니다. 예를 들어 이름만 바꾸려고 했는데 이메일, 전화번호, 선택 옵션이 사라지는 경우입니다.
type FormState = {
name: string;
email: string;
phone: string;
};
const [form, setForm] = useState<FormState>({
name: '',
email: '',
phone: '',
});
function changeName(name: string) {
setForm({ name });
}
위 코드의 명시된 FormState 타입에서는 email과 phone 누락으로 TypeScript 오류가 납니다. JavaScript에서도 객체를 병합하지 않고 교체하므로 기존 email, phone 값을 유지하지 않습니다. 객체 state에서 일부 필드만 바꿀 때는 기존 객체를 복사한 뒤 필요한 필드만 덮어쓰는 형태가 되어야 합니다.
function changeName(name: string) {
setForm((prevForm) => ({
...prevForm,
name,
}));
}
입력 폼에서 여러 필드를 하나의 객체로 관리한다면 이 패턴을 반복해서 쓰게 됩니다. 필드가 많아질수록 직접 대입 방식은 문제가 커지고, 업데이트 함수를 따로 분리하는 편이 유지보수에 유리합니다.
중복 state 때문에 화면이 어긋나는 경우
state 업데이트 코드를 고쳤는데도 화면이 어긋난다면 중복 state를 의심해야 합니다. 같은 의미의 데이터를 두 곳에 저장하면 한쪽만 업데이트되고 다른 쪽은 이전 값으로 남을 수 있습니다. 이 문제는 목록과 선택 항목을 동시에 들고 있을 때 자주 나옵니다.
type Product = {
id: number;
name: string;
price: number;
};
const [products, setProducts] = useState<Product[]>([]);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
위 구조에서 products 배열의 특정 상품 가격을 수정해도 selectedProduct에는 예전 상품 객체가 남아 있을 수 있습니다. 그러면 목록에서는 새 가격이 보이고, 상세 패널에서는 이전 가격이 보이는 식으로 화면이 어긋납니다. 이때는 selected 객체 전체를 state로 들고 있기보다 id만 저장하는 구조를 고려할 수 있습니다.
const [products, setProducts] = useState<Product[]>([]);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
const selectedProduct = products.find(
(product) => product.id === selectedProductId
) ?? null;
이렇게 만들면 선택된 상품 정보는 항상 최신 products 배열에서 계산됩니다. 별도로 동기화해야 할 state가 줄어들기 때문에 업데이트 누락도 줄어듭니다. state가 화면과 맞지 않을 때는 setter 코드만 보지 말고, 같은 데이터를 여러 state에 나눠 저장하고 있는지도 같이 봐야 합니다.
수정 후 점검 체크리스트

수정 후에는 화면이 한 번 바뀌는지만 보지 말고 같은 패턴이 다른 코드에 남아 있는지 확인해야 합니다. 목록 추가를 고쳤다면 삭제, 수정, 정렬, 필터링 코드도 같은 기준으로 봐야 합니다. 특히 state 업데이트 문제는 한 함수에서만 끝나지 않고 같은 컴포넌트 안에 비슷한 코드가 여러 개 남아 있는 경우가 많습니다.
| 확인할 부분 | 점검 기준 |
|---|---|
| 배열 추가 | push 대신 [...prev, item]처럼 새 배열을 반환하는지 봅니다. |
| 배열 삭제 | splice 대신 filter로 새 배열을 만드는지 확인합니다. |
| 배열 수정 | map 안에서 바뀌는 항목만 새 객체로 교체하는지 봅니다. |
| 배열 정렬 | sort를 state 배열에 바로 쓰지 않고 복사본에 쓰는지 확인합니다. |
| 객체 수정 | { ...prev, key: value } 형태로 기존 필드를 보존하는지 봅니다. |
| 중첩 객체 | 바뀌는 depth마다 새 객체를 만드는지 확인합니다. |
| 연속 업데이트 | 이전 값에 의존한다면 함수형 업데이트를 사용하는지 봅니다. |
| 로그 확인 | setState 직후 로그와 렌더링 이후 값을 구분합니다. |
| 중복 state | 같은 데이터를 여러 state에 저장해 동기화 문제가 생기지 않는지 봅니다. |
디버깅할 때는 먼저 이벤트 핸들러 안에서 setter가 호출되는지 확인합니다. 그 다음 setter에 넘기는 값이 기존 state를 직접 수정한 결과인지, 새 참조인지 확인합니다. 마지막으로 렌더링 이후 값이 의도대로 바뀌었는지 봅니다. 이 순서를 지키면 “클릭은 되는데 화면이 안 바뀐다”는 식의 막연한 문제를 코드 단위로 좁힐 수 있습니다.
컴포넌트가 커졌다면 state 구조 자체도 정리할 필요가 있습니다. 하나의 컴포넌트에서 목록, 선택 값, 필터 값, 정렬 값, 상세 객체를 모두 들고 있으면 업데이트 누락이 생기기 쉽습니다. 단순한 값은 id나 key만 state로 두고, 화면에 보여줄 데이터는 렌더링 시점에 계산하는 방식이 더 안정적인 경우가 많습니다.
마무리 점검
React state를 바꿨는데 화면이 업데이트되지 않는 문제는 대부분 “값을 바꿨는가”보다 “React에 새 상태를 전달했는가”에서 갈립니다. 배열은 push, splice, 직접 인덱스 대입처럼 원본을 바꾸는 코드를 피하고, filter, map, spread 문법으로 새 배열을 만들어야 합니다. 객체는 바뀌는 단계마다 새 객체를 반환해야 합니다.
setState 직후의 로그도 따로 봐야 합니다. 그 로그가 이전 값을 보여준다고 해서 업데이트가 실패한 것은 아닙니다. 현재 렌더링의 값과 다음 렌더링에 반영될 값을 구분해야 합니다. 이전 state에 의존해 다음 값을 계산한다면 함수형 업데이트를 사용하는 편이 안전합니다.
마지막으로 같은 데이터를 여러 state에 중복 저장하고 있지 않은지도 확인해야 합니다. setter 코드는 맞는데 화면 일부만 이전 값으로 남는다면, 선택 객체나 파생 데이터를 별도 state로 들고 있는 구조가 원인일 수 있습니다. 직접 mutation, 같은 참조 반환, 렌더링 스냅샷, 함수형 업데이트, 중복 state 순서로 보면 대부분의 state 업데이트 문제를 빠르게 좁힐 수 있습니다.
참고 기준은 React 공식 문서의 useState, State as a Snapshot, Queueing a Series of State Updates, Updating Arrays in State, Updating Objects in State, Choosing the State Structure를 함께 보면 됩니다.
직접 실행하는 최소 재현과 수정 실습
실습 ZIP 다운로드 · 3023번 글의 완성 프로젝트입니다. Node.js 22.12 이상에서 압축을 풀고 아래 명령을 실행합니다. HTML, CSS, JSX가 별도 파일이며 외부 API는 사용하지 않습니다.
npm install
npm run dev
# 결과 검증
npm test
npm run build
npm run preview
1. 원인을 확인하는 최소 오류 코드
cart.product.quantity += 1;
setCart(cart);
이벤트에서 중첩 객체를 직접 수정하고 같은 cart 참조를 넘기면 화면이 이전 수량으로 남을 수 있습니다. 수정본은 cart와 product를 모두 새 객체로 만들고 이름·가격은 유지합니다. +3 버튼은 하나의 이벤트에서 같은 순수 updater를 세 번 큐에 넣어 누적시킵니다. 합계는 quantity × price로 계산하여 동기화할 state를 늘리지 않습니다.
2. App.jsx 수정본
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
이 실습은 오류를 재현하는 시작본과 설명, 검증된 완성본을 차례로 비교합니다. 수량 +1, 수량 +3을 차례로 누르세요. 완성본은 수량 2, 5와 합계 1000원, 2500원을 표시합니다.
수정 전
App.jsx
import React, { useState } from 'react';
export default function App() {
const [cart, setCart] = useState({
product: { name: '연필', price: 500, quantity: 1 },
});
function increase() {
cart.product.quantity += 1;
setCart(cart);
}
function increaseThree() {
increase();
increase();
increase();
}
const { name, quantity, price } = cart.product;
return (
<main>
<h1>중첩 state 업데이트</h1>
<p role="status">
{name} · 수량 {quantity} · 합계 {quantity * price}원
</p>
<button type="button" onClick={increase}>
수량 +1
</button>
<button type="button" onClick={increaseThree}>
수량 +3
</button>
<p>
이름과 가격을 보존하면서 다음 수량을 계산합니다. 합계는 별도 state가 아닙니다.
</p>
</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('updates nested quantity, preserves label and accumulates queued updates', async () => {
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByRole('button', { name: '수량 +1' }));
expect(screen.getByText('연필 · 수량 2 · 합계 1000원')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '수량 +3' }));
expect(screen.getByText('연필 · 수량 5 · 합계 2500원')).toBeTruthy();
});
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 실습 3023</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-3023",
"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
수정 위치: increase에서 cart.product.quantity를 직접 바꾸고 같은 cart를 setCart에 전달하는 부분을 확인하세요.
이유: React는 이전 state와 같은 객체 참조를 받으면 렌더를 생략할 수 있습니다. 바깥 객체와 product를 복사하고 함수형 업데이트로 이전 수량을 읽어야 연속 세 번의 업데이트도 모두 누적됩니다.
재현과 기대 결과: 수량 +1, 수량 +3을 차례로 누르세요. 완성본은 수량 2, 5와 합계 1000원, 2500원을 표시합니다.
App.jsx에서 아래 차이가 있는 위치를 수정하세요. -는 삭제할 줄, +는 추가할 줄이며 표시는 실제 코드에 넣지 않습니다.
본문의 확인 절차로 변경 결과를 확인합니다.
--- 수정 전/App.jsx
+++ 수정 후/App.jsx
@@ -5,8 +5,10 @@
product: { name: '연필', price: 500, quantity: 1 },
});
function increase() {
- cart.product.quantity += 1;
- setCart(cart);
+ setCart((previous) => ({
+ ...previous,
+ product: { ...previous.product, quantity: previous.product.quantity + 1 },
+ }));
}
function increaseThree() {
increase();
수정 후 탭의 전체 파일과 비교한 뒤 본문의 확인 절차를 실행하세요.
App.test.jsx
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
index.html
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
main.jsx
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
package.json
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
styles.css
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
vite.config.js
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
수정 후
App.jsx
import React, { useState } from 'react';
export default function App() {
const [cart, setCart] = useState({
product: { name: '연필', price: 500, quantity: 1 },
});
function increase() {
setCart((previous) => ({
...previous,
product: { ...previous.product, quantity: previous.product.quantity + 1 },
}));
}
function increaseThree() {
increase();
increase();
increase();
}
const { name, quantity, price } = cart.product;
return (
<main>
<h1>중첩 state 업데이트</h1>
<p role="status">
{name} · 수량 {quantity} · 합계 {quantity * price}원
</p>
<button type="button" onClick={increase}>
수량 +1
</button>
<button type="button" onClick={increaseThree}>
수량 +3
</button>
<p>
이름과 가격을 보존하면서 다음 수량을 계산합니다. 합계는 별도 state가 아닙니다.
</p>
</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('updates nested quantity, preserves label and accumulates queued updates', async () => {
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByRole('button', { name: '수량 +1' }));
expect(screen.getByText('연필 · 수량 2 · 합계 1000원')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '수량 +3' }));
expect(screen.getByText('연필 · 수량 5 · 합계 2500원')).toBeTruthy();
});
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 실습 3023</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-3023",
"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' } });
3. 따라 해볼 순서와 완료 기준
수량 +1 → 수량 +3. 처음 수량 1에서 2, 5로 바뀌고 이름은 연필, 합계는 1000원, 2500원이어야 합니다.
ZIP의 main.jsx에서 React 루트와 StrictMode를 구성하고 styles.css에서 흑백 UI를 적용합니다. App.test.jsx는 실제 입력·클릭 뒤 화면 결과를 확인합니다. 오류 코드는 broken-example.md에 설명용으로 보관되어 자동 실행되지 않습니다. 테스트는 jsdom에서 검증하며 브라우저의 시각적 배치까지 검증한 것은 아닙니다.
공식 참고 자료
같이 읽으면 좋은 글
이 글이 도움이 되었나요?
React 학습 순서
필수 19개 · 전체 23개
읽음 기록 관리
전체 과정 목차 (23개)
- 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
- 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
- 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
- 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
- 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
- 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
- 선택 참고 · React state 업데이트 안됨 문제 해결 현재 글
- 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
- 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
- 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
- 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
- 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
- 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
- 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
- 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
- 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
- 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
- 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
- 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
- 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
- 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
- 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
- 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.