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가 큐에 쌓인 이전 업데이트 결과를 다음 계산의 입력값으로 넘겨줍니다. 그래서 같은 이벤트 안에서 여러 번 업데이트하더라도 이전 결과를 이어받아 계산할 수 있습니다. 단순 카운터뿐 아니라 선택 목록 누적, 최근 검색어 추가, 토글 상태 전환처럼 이전 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 });
}
위 코드는 TypeScript 설정에 따라 바로 오류가 날 수도 있지만, 구조만 보면 기존 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를 함께 보면 됩니다.
“React state 업데이트 안됨 문제 해결”에 대한 1개의 생각