Zustand 값은 바뀌는데 화면이 그대로일 때 확인할 순서
Zustand 상태 변경 후 리렌더링이 안 되는 문제는 대개 네 범주로 좁혀집니다. 기존 객체·배열을 직접 수정했거나, 컴포넌트가 변경된 값을 구독하지 않거나, selector가 이전과 같은 결과를 반환하거나, 화면과 업데이트 코드가 서로 다른 store 인스턴스를 쓰는 경우입니다. 아래 순서대로 store 변경 여부와 React 구독 여부를 분리해 확인하면 불필요한 수정 범위를 줄일 수 있습니다.
검증 기준: 2026-07-19, npm의 Zustand 5.0.14와 현재 공식 문서를 기준으로 예제를 확인했습니다. 4.x 코드를 유지하는 프로젝트라면 equality 함수 전달 방식 등 버전 차이를 먼저 확인하세요.
- 증상별 시작점
- store가 실제로 바뀌었는지 확인
- 객체·배열을 새 참조로 업데이트
- selector와 구독 범위 확인
- 객체 selector와 useShallow 적용
- store 인스턴스와 hydration 점검
- 동작하는 최소 예제
- 최종 체크리스트와 결론
증상별로 첫 확인 지점을 정합니다

| 관찰한 증상 | 가능성이 큰 원인 | 첫 확인 |
|---|---|---|
getState() 값도 그대로임 |
action 미호출, 조건문 조기 종료, 다른 store 호출 | action 시작·끝에 현재 값 기록 |
getState()는 바뀌지만 화면은 그대로임 |
구독하지 않은 필드, 동일한 selector 결과 | 컴포넌트의 selector 반환값 확인 |
| 객체의 내부 필드만 바꿀 때 재현됨 | 기존 참조 직접 수정, 중첩 객체 병합 누락 | 각 중첩 단계에 새 객체가 생기는지 확인 |
| 여러 값을 객체·배열로 묶을 때 렌더 기준이 이상함 | 매 렌더 새 결과 생성 또는 equality 방식 불일치 | 분리 구독 또는 useShallow 적용 |
| 새로고침 직후에만 초기 화면이 다름 | persist 재수화 시점 |
일반 리렌더 문제와 hydration 문제를 분리 |
1단계: store가 실제로 바뀌었는지 화면과 분리해 확인합니다
먼저 “React가 다시 그리지 않는다”와 “store 자체가 바뀌지 않았다”를 분리해야 합니다. action을 호출하기 전후에 같은 store의 getState()를 확인하면 첫 분기가 명확해집니다. 운영 코드에 로그를 계속 남기기보다 로컬 재현에서 잠시 사용하고 제거하세요.
console.log('before', useUserStore.getState().user);
useUserStore.getState().rename('Haebi');
console.log('after', useUserStore.getState().user);
after 값도 그대로라면 selector를 고칠 단계가 아닙니다. 클릭 핸들러가 실행되는지, action이 조건문에서 끝나지 않는지, import한 store가 화면에서 쓰는 store와 같은 파일·인스턴스인지 확인하세요. 반대로 store 값은 바뀌었는데 화면만 그대로라면 다음 단계에서 참조와 구독 범위를 봅니다.
Zustand 공식 Flux 방식 권장 사항은 상태 변경에 set 또는 setState를 사용해 병합과 listener 통지를 거치도록 안내합니다.
2단계: 객체와 배열은 새 참조로 업데이트합니다
기존 객체를 직접 바꾼 뒤 같은 객체를 반환하면 값은 달라 보여도 구독 결과의 참조가 그대로일 수 있습니다. 특히 외부에서 getState()로 꺼낸 객체를 직접 수정하는 코드는 피해야 합니다. 상태는 set 안에서 새 객체나 새 배열로 만들어 반환하세요.
// 피해야 할 코드: 기존 객체와 같은 참조를 다시 반환
renameWrong: (name) =>
set((state) => {
state.user.name = name;
return { user: state.user };
}),
// 권장 코드: 변경되는 중첩 단계에 새 참조 생성
rename: (name) =>
set((state) => ({
user: { ...state.user, name },
})),
중첩 객체는 한 단계 병합만 된다는 점을 확인합니다
Zustand의 set은 기본적으로 최상위 한 단계만 얕게 병합합니다. user.profile.name처럼 더 깊은 값을 바꾸면 user와 profile을 각각 복사해야 합니다. 이 동작은 공식 불변 상태와 병합 가이드와 상태 업데이트 가이드에서 확인할 수 있습니다.
set((state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
name: 'Haebi',
},
},
}));
배열도 push, splice, sort로 원본을 바꾸기보다 map, filter, 전개 구문 등으로 새 배열을 반환합니다. Map과 Set 역시 내부를 수정한 같은 인스턴스가 아니라 새 인스턴스를 만들어야 합니다.
3단계: 컴포넌트가 변경된 값을 실제로 구독하는지 봅니다
컴포넌트는 store 전체가 아니라 selector가 반환한 값의 변화를 기준으로 다시 렌더링됩니다. user.age를 바꿨는데 컴포넌트가 user.name만 구독한다면 다시 그려지지 않는 것이 정상입니다. 화면에 필요한 값을 selector가 실제로 반환하는지 확인하세요.
// name이 바뀔 때만 다시 렌더링
const name = useUserStore((state) => state.user.name);
// age도 화면에 필요하면 별도로 구독
const age = useUserStore((state) => state.user.age);
진단 중에는 selector 안에 복잡한 변환을 넣기 전에 원시 필드를 각각 구독해 보세요. 이 상태에서 화면이 갱신되면 action보다 selector 결과 생성 방식이 원인일 가능성이 큽니다. selector 최적화의 원리는 Zustand 리렌더링 원리와 selector 최적화 방법에서 이어서 볼 수 있습니다.
4단계: 계산된 객체·배열 selector는 useShallow 필요 여부를 판단합니다

selector가 객체나 배열을 새로 만들면 결과 비교 방식이 렌더 횟수에 영향을 줍니다. Zustand 5의 기본 create 사용 예에서는 필요한 값을 각각 구독하는 방식이 가장 단순합니다. 여러 값을 한 번에 묶어야 하고 각 최상위 값이 같을 때 렌더를 건너뛰려면 공식 가이드의 useShallow 패턴을 사용할 수 있습니다.
import { useShallow } from 'zustand/react/shallow';
const { name, rename } = useUserStore(
useShallow((state) => ({
name: state.user.name,
rename: state.rename,
})),
);
useShallow는 결과의 최상위 값을 얕게 비교합니다. 깊은 중첩 객체 내부를 직접 수정한 문제를 해결해 주는 도구는 아닙니다. 먼저 불변 업데이트를 보장한 다음 렌더 최적화 목적으로 적용하세요. 정확한 동작과 현재 import 경로는 공식 useShallow로 불필요한 렌더 방지하기 문서를 기준으로 확인할 수 있습니다.
5단계: 서로 다른 store 인스턴스와 persist hydration을 구분합니다
화면과 action이 같은 store를 import하는지 확인합니다
컴포넌트는 A 인스턴스를 구독하는데 이벤트 핸들러는 B 인스턴스를 바꾸면 양쪽 모두 정상 동작하는 것처럼 보여도 화면은 갱신되지 않습니다. store factory를 렌더마다 호출하거나, provider 안팎에서 서로 다른 vanilla store를 만들거나, 별칭 경로와 상대 경로가 중복 모듈로 번들되는 구조를 점검하세요. store 생성은 정해진 소유 위치에서 한 번만 하고 화면과 action이 같은 export를 사용하도록 맞춥니다.
새로고침 직후에만 다르면 persist 재수화를 따로 봅니다
버튼을 눌렀을 때 상태 변경이 반영되지 않는 문제와, 새로고침 직후 저장 상태가 늦게 복원되는 문제는 원인이 다릅니다. 후자는 storage를 읽어오는 재수화 시점, 저장된 version, migrate 동작을 확인해야 합니다. 상태 구조가 바뀐 배포라면 Zustand persist 마이그레이션 기준을 함께 확인하세요.
동작하는 최소 예제로 비교합니다
아래 예제는 새 객체를 반환하고, 컴포넌트가 변경되는 원시 값과 action을 각각 구독합니다. 현재 코드와 이 예제를 비교할 때 store 생성 위치, action 호출, selector 반환값을 한 항목씩 바꾸면 원인을 좁히기 쉽습니다.
import { create } from 'zustand';
type User = { name: string; age: number };
type UserStore = {
user: User;
rename: (name: string) => void;
};
export const useUserStore = create<UserStore>()((set) => ({
user: { name: 'Guest', age: 20 },
rename: (name) =>
set((state) => ({
user: { ...state.user, name },
})),
}));
export function ProfileName() {
const name = useUserStore((state) => state.user.name);
const rename = useUserStore((state) => state.rename);
return (
<button type="button" onClick={() => rename('Haebi')}>
현재 이름: {name}
</button>
);
}
이 예제도 갱신되지 않으면 브라우저 콘솔 오류, 이벤트 차단, 중복 React/모듈 인스턴스 같은 프로젝트 환경을 확인합니다. 반대로 최소 예제는 동작한다면 기존 store에 객체 직접 수정, 조건부 action, 복잡한 selector, provider 경계 중 하나가 추가되면서 문제가 생긴 것입니다.
최종 체크리스트와 결론
결론: 가장 먼저 store가 실제로 바뀌는지 확인하고, 다음으로 새 참조와 selector 구독 범위를 검사하세요. useShallow는 불변 업데이트 오류를 가리는 해결책이 아니라 계산된 selector의 렌더 기준을 조정하는 도구입니다. 이 순서를 지키면 “Zustand가 리렌더링하지 않는다”는 넓은 증상을 action, 참조, selector, 인스턴스 문제로 구체화할 수 있습니다.
다음 학습 경로
- Zustand 기본 사용법과 실전 글 모음 — store와 action 구조를 처음부터 점검할 때
- Zustand 리렌더링 원리와 selector 최적화 방법 — 렌더 기준을 더 깊게 이해할 때
- React state vs Zustand — 전역 상태가 필요한 범위를 다시 판단할 때
업데이트 기록: 2026-07-19 — Zustand 5.0.14 공식 문서 기준으로 equality 예제를 useShallow 방식으로 교정하고, store 인스턴스·persist hydration 진단 절차를 보강했습니다.
“Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법”에 대한 8개의 생각